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 c7c4c43..52a5dfe 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 @@ -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? = 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>, 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>, + 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): List = optionSignature(row.sortedBy { it.node.bounds.left }) 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 6fd457b..d3798ea 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 @@ -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)) }