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 7922af6..c2112ed 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 @@ -1705,9 +1705,10 @@ class PddProductDetailCollector( break } diagnosticHorizontalSwipes++ - pause(350) + val settleStartedAt = now() + pause(HORIZONTAL_SETTLE_MILLIS) lastHorizontalSwipeDoneAt = now() - settleColorGrid(goodsId, config, evidence) + trace("color-settle waitedMs=${now() - settleStartedAt}") } trace("color-row-end row=$rowIndex end=$rowEnd skip=$rowSkipNote") // An uncertain revisit cannot erase previously observed @@ -1767,35 +1768,6 @@ 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 } @@ -1993,7 +1965,9 @@ class PddProductDetailCollector( var anchorFailures = 0 var confirmed = false var end = "limit" - for (attempt in 0 until config.limits.getValue("specHorizontalSwipes")) { + // Reset has its own swipe budget; anchor retries do not consume it. + val maxSwipes = config.limits.getValue("specHorizontalSwipes") * 2 + while (swipes < maxSwipes) { if (now() > deadline) return ColorStart(failure("RULE_NOT_MATCHED", "采集超过规则总超时"), false) val screen = parse(goodsId, config, evidence) screen.problem?.let { return ColorStart(failure(it.code, it.message), false) } @@ -2025,7 +1999,11 @@ class PddProductDetailCollector( // equal read must not count as stability. previous = emptyList() stable = 0 - if ((reason == "anchorMissing" || reason == "anchorAmbiguous") && anchorFailures <= 2) { + if ( + (reason == "anchorMissing" || reason == "anchorAmbiguous") && + anchorFailures <= MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES && + anchorRetries < MAX_RESET_ANCHOR_RETRIES + ) { anchorRetries++ continue } @@ -2390,6 +2368,10 @@ class PddProductDetailCollector( private companion object { const val PRICE_SETTLE_MILLIS = 300L + /** Fixed wait for the color grid to stop coasting after a horizontal swipe (no extra parse). */ + const val HORIZONTAL_SETTLE_MILLIS = 1200L + const val MAX_RESET_ANCHOR_RETRIES = 6 + const val MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES = 2 const val SPEC_PANEL_LOST_STABLE_READS = 2 const val COLOR_IMAGE_MAX_COUNT = 64 const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024 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 d73ceec..385e285 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 @@ -1213,7 +1213,7 @@ class PddProductDetailCollectorTest { ) var clock = 0L - val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 60_000)))) assertTrue(result.successful) assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values) @@ -2187,23 +2187,32 @@ class PddProductDetailCollectorTest { 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): Pair { + private fun midStartGrid( + failAttempts: Set, + initialOffset: Int = 2000, + traces: MutableList = mutableListOf(), + captureCostMillis: Long = 0L, + elapsed: LongArray = LongArray(1), + ): Pair { 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() + var clock = 0L val driver = FakeCollectorDriver( colors = colors, prices = prices, horizontalGrid = grid, gridPixelStep = 640, - gridInitialOffset = 2000, + captureCostMillis = captureCostMillis, + captureAdvance = { clock += it }, + gridInitialOffset = initialOffset, gridRightFailAttempts = failAttempts, sizePages = listOf(listOf("S"), listOf("M")), hideColorHeadingAfterFirstVerticalPage = true, ) - var clock = 0L - val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370) + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370) .collect(GOODS_ID, rule(gridConfig())) + elapsed[0] = clock return driver to result } @@ -2217,45 +2226,73 @@ class PddProductDetailCollectorTest { assertTrue(payload.missing.isEmpty()) } - private class SettleRun(val driver: FakeCollectorDriver, val result: PddCollectorResult, val traces: List) + private class SettleRun( + val driver: FakeCollectorDriver, + val result: PddCollectorResult, + val traces: List, + val elapsedMs: Long, + ) - private fun inertiaGrid(inertia: Int): SettleRun { + // The grid keeps coasting until HORIZONTAL_SETTLE_MILLIS-ish of simulated + // clock time has passed since the last movement (timeBased), mirroring the + // ~1-1.3s measured on a device. + private fun inertiaGrid(timeBased: Boolean): 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() + var clock = 0L val driver = FakeCollectorDriver( colors = colors, prices = prices, horizontalGrid = grid, gridPixelStep = 640, - gridInertiaCaptures = inertia, + gridSettleClock = if (timeBased) ({ clock }) else null, + gridSettleMillis = if (timeBased) 1000L else 0L, 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) + return SettleRun(driver, result, traces, clock) } @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) + fun `color grid waits a fixed time after a horizontal swipe without extra parses`() { + val calm = inertiaGrid(timeBased = false) + val coasting = inertiaGrid(timeBased = true) + println("SIM_ELAPSED_47_COLORS=${coasting.elapsedMs}ms settles=${coasting.traces.count { it.startsWith("color-settle") }}") 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") }) + settles.forEach { assertEquals("color-settle waitedMs=1200", it) } + // No polling reads: the same captures and clicks as a grid that never coasts. + assertEquals(calm.driver.captureCount, coasting.driver.captureCount) + assertEquals(calm.driver.clickCount, coasting.driver.clickCount) + } + + @Test + fun `reset from the right end with scattered anchor failures still confirms the left end`() { + val traces = mutableListOf() + val elapsed = LongArray(1) + val (driver, result) = midStartGrid( + failAttempts = setOf(1, 3, 5, 7), + initialOffset = 4675, + traces = traces, + captureCostMillis = 900L, + elapsed = elapsed, + ) + println("SIM_ELAPSED_RIGHT_START=${elapsed[0]}ms") + assertTrue("elapsed ${elapsed[0]}", elapsed[0] < BASELINE_RIGHT_START_ELAPSED_MS * 7 / 10) + + assertAll47Collected(result) + assertTrue(traces.toString(), traces.any { it.startsWith("color-start confirmed=true") && it.contains("anchorRetries=4") }) + 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 @@ -2452,6 +2489,13 @@ class PddProductDetailCollectorTest { // #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: time-based coasting: captures within gridSettleMillis of the last + // grid movement (by gridSettleClock) show the nodes drifted by 30px. + private val gridSettleClock: (() -> Long)? = null, + private val gridSettleMillis: Long = 0L, + // #370: each capture() (a parse) costs this much simulated time. + private val captureCostMillis: Long = 0L, + private val captureAdvance: ((Long) -> Unit)? = null, // #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, @@ -2469,6 +2513,9 @@ class PddProductDetailCollectorTest { private var rightAttempts = 0 private var inertiaRemaining = 0 private var currentDrift = 0 + // Parses between a spec swipe and the next click (discovery) cost captureCostMillis. + private var costlyParses = false + private var lastGridMoveAt = Long.MIN_VALUE / 2 private var lastSwipeFailure = "none" private var selected: String? = initialSelectedColor private var previousSelected: String? = null @@ -2481,11 +2528,15 @@ class PddProductDetailCollectorTest { private var quickConfirmationRecovered = false private var adviceOpen = false - override fun capture(): UiSnapshot = captureFrame(advanceInertia = true) + override fun capture(): UiSnapshot { + if (captureCostMillis > 0 && costlyParses) captureAdvance?.invoke(captureCostMillis) + return captureFrame(advanceInertia = true) + } private fun captureFrame(advanceInertia: Boolean): UiSnapshot { captureCount++ - currentDrift = inertiaRemaining * 30 + currentDrift = inertiaRemaining * 30 + + if (gridSettleClock != null && gridSettleClock.invoke() - lastGridMoveAt < gridSettleMillis) 30 else 0 if (advanceInertia && inertiaRemaining > 0) inertiaRemaining-- fixedSnapshot?.let { return it } if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( @@ -2643,6 +2694,7 @@ class PddProductDetailCollectorTest { override fun clickFresh(target: SnapshotNode): FreshActionResult { clickCount++ + costlyParses = false clickedLabels += target.label if (target.label in failedClicks) return FreshActionResult.FAILED val current = captureFrame(advanceInertia = false).nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) } @@ -2691,6 +2743,7 @@ class PddProductDetailCollectorTest { override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { swipes += direction to anchor + costlyParses = true lastSwipeFailure = "none" if (direction == SwipeDirection.RIGHT && horizontalGrid != null) { rightAttempts++ @@ -2717,7 +2770,10 @@ class PddProductDetailCollectorTest { 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 + if (gridOffsets.toList() != offsetsBefore) { + inertiaRemaining = gridInertiaCaptures + gridSettleClock?.let { lastGridMoveAt = it.invoke() } + } return true } if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false @@ -2757,6 +2813,8 @@ class PddProductDetailCollectorTest { companion object { private const val GOODS_ID = "719834019024" private const val GRID_VIEWPORT_RIGHT = 835 + // Simulated elapsed time of the right-start 47 color scenario at 9619c38. + private const val BASELINE_RIGHT_START_ELAPSED_MS = 127_550L private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"