diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 08aa6e2..4effa7a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -30,6 +30,9 @@ android { } buildTypes { + debug { + versionNameSuffix = "-370-diag1" + } release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 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 d298d72..42606f3 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 @@ -1392,6 +1392,12 @@ class PddProductDetailCollector( var diagnosticInitialSelectedSizes = 0 var diagnosticSelectedSummaryPresent = false var diagnosticHorizontalSwipes = 0 + var diagnosticVerticalSwipes = 0 + var diagnosticTrailingEmptyReads = 0 + var diagnosticHorizontalUnknown = false + // Exact seed sets can reconcile a later stable visit to the same row. + // These signatures stay in memory; only the aggregate tri-state is saved. + val diagnosticHorizontalRows = mutableMapOf, Boolean?>() var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND val imageAttempts = mutableSetOf() @@ -1448,6 +1454,13 @@ class PddProductDetailCollector( initialSelectedSizeCount = diagnosticInitialSelectedSizes, selectedSummaryPresent = diagnosticSelectedSummaryPresent, horizontalSwipes = diagnosticHorizontalSwipes, + verticalSwipes = diagnosticVerticalSwipes, + trailingEmptyReads = diagnosticTrailingEmptyReads, + horizontalIncomplete = when { + diagnosticHorizontalRows.values.any { it == true } -> true + diagnosticHorizontalUnknown || diagnosticHorizontalRows.values.any { it == null } -> null + else -> false + }, elapsedMs = now() - diagnosticStartedAt, ) } @@ -1464,6 +1477,8 @@ class PddProductDetailCollector( val initialRows = colorRows(initial) observeColorDiscovery(initial, initialRows) if (initialRows.isEmpty()) { + diagnosticTrailingEmptyReads = 1 + diagnosticHorizontalUnknown = true finishColorDiscovery( if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE else AgentDiagnosticReason.COLOR_EDGE_REACHED, @@ -1471,7 +1486,8 @@ class PddProductDetailCollector( return null } val attempted = mutableSetOf() - fun collectVisibleColors(): PddCollectorResult? { + fun collectVisibleColors(onFirstRows: ((List>) -> Unit)? = null): PddCollectorResult? { + var firstRead = true while (true) { if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") val screen = parse(goodsId, config, evidence) @@ -1479,6 +1495,10 @@ class PddProductDetailCollector( if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name } val rows = colorRows(screen) + if (firstRead) { + onFirstRows?.invoke(rows) + firstRead = false + } observeColorDiscovery(screen, rows) rows.flatten().filter { SpecValueNormalizer.isSafeColor(it.text) }.forEach { value -> colors[value.text] = colors[value.text] == true || value.available @@ -1531,8 +1551,18 @@ class PddProductDetailCollector( rowSeeds.forEachIndexed { rowIndex, seed -> val moveRight = rowIndex % 2 == 0 val horizontalSignatureReads = mutableMapOf, Int>() + var diagnosticPreviousSignature: List? = null + var diagnosticStableReads = 0 + var diagnosticMoved = false + var diagnosticRowUncertain = false + var diagnosticRowIncomplete: Boolean? = null for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) { - collectVisibleColors()?.let { return it } + var diagnosticBeforeClicks: List? = null + collectVisibleColors { observedRows -> + diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } } + .singleOrNull()?.sortedBy { it.node.bounds.left } + ?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature) + }?.let { return it } screen = parse(goodsId, config, evidence) screen.problem?.let { return failure(it.code, it.message) } if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") @@ -1541,21 +1571,49 @@ class PddProductDetailCollector( val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } } ?.takeIf { row -> row.any { it.text in seed } } if (matchedRow == null) { + diagnosticHorizontalUnknown = true diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED break } val currentRow = matchedRow.sortedBy { it.node.bounds.left } .let { if (moveRight) it else it.reversed() } val signature = optionSignature(currentRow) + // Observe the frame immediately after the prior swipe, + // before clicking newly visible values. A click reflow or + // ambiguous row match cannot prove horizontal movement. + if (diagnosticBeforeClicks == null || diagnosticBeforeClicks != signature) { + diagnosticRowUncertain = true + diagnosticHorizontalUnknown = true + } + if (!diagnosticRowUncertain && diagnosticPreviousSignature != null && signature != diagnosticPreviousSignature) { + diagnosticMoved = true + } + diagnosticStableReads = if (signature == diagnosticPreviousSignature) diagnosticStableReads + 1 else 0 + diagnosticPreviousSignature = signature val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1 horizontalSignatureReads[signature] = signatureReads - if (signatureReads > config.limits.getValue("stableEdgeReads")) break - if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) break + if (signatureReads > config.limits.getValue("stableEdgeReads")) { + if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) { + diagnosticRowIncomplete = false + } + break + } + if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) { + if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true + break + } val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT - if (!driver.swipeSpec(direction, currentRow.first().node)) break + if (!driver.swipeSpec(direction, currentRow.first().node)) { + if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true + break + } diagnosticHorizontalSwipes++ pause(350) } + // An uncertain revisit cannot erase previously observed + // unresolved movement; only reliable stability clears it. + diagnosticHorizontalRows[seed] = diagnosticRowIncomplete + ?: diagnosticHorizontalRows[seed]?.takeIf { it } } collectVisibleColors()?.let { return it } screen = parse(goodsId, config, evidence) @@ -1564,6 +1622,10 @@ class PddProductDetailCollector( observeColorDiscovery(screen, rows) } + // One observation per outer discovery round, never per click or + // stable-price reread. Empty parsed rows do not prove an edge. + diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0 + if (rows.isEmpty()) diagnosticHorizontalUnknown = true if (!discoverVertically) { diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED break @@ -1586,6 +1648,7 @@ class PddProductDetailCollector( } val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer if (anchor == null) { + diagnosticHorizontalUnknown = true diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE break } @@ -1593,6 +1656,7 @@ class PddProductDetailCollector( diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED break } + diagnosticVerticalSwipes++ pause(350) } finishColorDiscovery() @@ -1621,6 +1685,9 @@ class PddProductDetailCollector( initialSelectedSizeCount: Int, selectedSummaryPresent: Boolean, horizontalSwipes: Int, + verticalSwipes: Int, + trailingEmptyReads: Int, + horizontalIncomplete: Boolean?, elapsedMs: Long, ) { if (taskId <= 0) return @@ -1639,6 +1706,9 @@ class PddProductDetailCollector( initialSelectedSizeCount = initialSelectedSizeCount, selectedSummaryPresent = selectedSummaryPresent, horizontalSwipeCount = horizontalSwipes, + colorVerticalSwipeCount = verticalSwipes, + colorTrailingEmptyReadCount = trailingEmptyReads, + colorHorizontalIncomplete = horizontalIncomplete, ), ) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt index edaab41..fcd0ef4 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt @@ -1,7 +1,7 @@ package cn.ilapage.goauto.agent.persistence internal object AgentDiagnosticSchema { - const val VERSION = 4 + const val VERSION = 5 val failureSnapshotStatements = listOf( """CREATE TABLE IF NOT EXISTS purchase_failure_snapshot ( @@ -38,6 +38,12 @@ internal object AgentDiagnosticSchema { "horizontal_swipe_count" to "INTEGER", ) + val colorDiscoveryOutcomeColumns = linkedMapOf( + "color_vertical_swipe_count" to "INTEGER", + "color_trailing_empty_read_count" to "INTEGER", + "color_horizontal_incomplete" to "INTEGER", + ) + val createTableSql = """CREATE TABLE agent_diagnostic ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -66,6 +72,9 @@ internal object AgentDiagnosticSchema { initial_selected_size_count INTEGER, selected_summary_present INTEGER, horizontal_swipe_count INTEGER, + color_vertical_swipe_count INTEGER, + color_trailing_empty_read_count INTEGER, + color_horizontal_incomplete INTEGER, task_type TEXT, task_attempt_id TEXT, device_id INTEGER, @@ -87,5 +96,8 @@ internal object AgentDiagnosticSchema { (if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) -> if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" } else emptyList()) + - (if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList()) + (if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList()) + + (if (oldVersion < 5 && newVersion >= 5) colorDiscoveryOutcomeColumns.mapNotNull { (name, definition) -> + if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" + } else emptyList()) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 752f1f1..0b18a06 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -114,6 +114,12 @@ data class AgentDiagnosticEvent( val deviceId: Long? = null, val phase: String? = null, val ruleSnapshotHash: String? = null, + /** Successfully dispatched UP swipes during this color discovery; null means unknown. */ + val colorVerticalSwipeCount: Int? = null, + /** Consecutive empty parsed-color discovery rounds at termination, excluding click/price reads. */ + val colorTrailingEmptyReadCount: Int? = null, + /** Required horizontal exploration still unresolved at termination; not needed or recovered is false. */ + val colorHorizontalIncomplete: Boolean? = null, ) class SafeAgentDiagnosticRecorder( @@ -197,6 +203,9 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount) putNullableBoolean("selected_summary_present", event.selectedSummaryPresent) putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount) + putNullableInt("color_vertical_swipe_count", event.colorVerticalSwipeCount) + putNullableInt("color_trailing_empty_read_count", event.colorTrailingEmptyReadCount) + putNullableBoolean("color_horizontal_incomplete", event.colorHorizontalIncomplete) put("agent_version", BuildConfig.VERSION_NAME) put("created_at", event.createdAt) } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt index 6a90816..6322afe 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/AgentDiagnosticRecorderTest.kt @@ -9,6 +9,27 @@ import org.junit.Assert.assertEquals import org.junit.Test class AgentDiagnosticRecorderTest { + @Test + fun colorDiscoveryOutcomeDefaultsToUnknownAndPreservesExplicitValues() { + val event = AgentDiagnosticEvent( + taskId = 370, + stage = AgentDiagnosticStage.COLOR_DISCOVERY, + reason = AgentDiagnosticReason.COLOR_EDGE_REACHED, + ) + assertEquals(null, event.colorVerticalSwipeCount) + assertEquals(null, event.colorTrailingEmptyReadCount) + assertEquals(null, event.colorHorizontalIncomplete) + val completed = event.copy( + colorVerticalSwipeCount = 4, + colorTrailingEmptyReadCount = 2, + colorHorizontalIncomplete = false, + ) + assertEquals(4, completed.colorVerticalSwipeCount) + assertEquals(2, completed.colorTrailingEmptyReadCount) + assertEquals(false, completed.colorHorizontalIncomplete) + assertEquals(true, completed.copy(colorHorizontalIncomplete = true).colorHorizontalIncomplete) + } + @Test fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() { assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS) 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 3e39802..7617940 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 @@ -1220,6 +1220,246 @@ class PddProductDetailCollectorTest { assertTrue(driver.swipes.any { it.first == SwipeDirection.UP }) } + @Test + fun `color discovery counts only its own successful vertical swipes`() { + val colors = listOf("A色", "B色", "C色") + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = colors, + colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)), + sizePages = listOf(listOf("S"), listOf("M"), listOf("L")), + rowSize = 1, + hideColorHeadingAfterFirstVerticalPage = true, + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add) + .collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000)))) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(2, event.colorVerticalSwipeCount) + assertTrue(driver.swipes.count { it.first == SwipeDirection.UP } > requireNotNull(event.colorVerticalSwipeCount)) + assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN }) + } + + @Test + fun `color discovery failed vertical dispatch is not counted`() { + val events = mutableListOf() + val driver = FakeCollectorDriver(colors = listOf("A色", "B色"), rowSize = 1, verticalSwipeSucceeds = false) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add) + .collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(AgentDiagnosticReason.COLOR_SWIPE_FAILED, event.reason) + assertEquals(0, event.colorVerticalSwipeCount) + assertEquals(0, event.colorTrailingEmptyReadCount) + assertEquals(false, event.colorHorizontalIncomplete) + } + + @Test + fun `color discovery counts empty parsed rounds after heading scrolls away without counting price reads`() { + val colors = listOf("A色", "B色", "C色", "D色") + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = colors, + colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)), + rowSize = 1, + hideColorHeadingAfterFirstVerticalPage = true, + priceDelayReads = colors.associateWith { 2 }, + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add) + .collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000)))) + + assertTrue(result.successful) + // This fixture retains the options while their heading disappears. The + // existing parser/traversal still misses the second page in this stage. + assertEquals(colors.take(2), requireNotNull(result.payload).dimensions.first { it.key == "color" }.values) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(2, event.colorTrailingEmptyReadCount) + assertEquals(2, event.colorVerticalSwipeCount) + assertEquals(null, event.colorHorizontalIncomplete) + } + + @Test + fun `color discovery initially empty is one empty round with unknown horizontal state`() { + val events = mutableListOf() + var clock = 0L + + val result = PddProductDetailCollector( + FakeCollectorDriver(colors = emptyList()), { clock }, { clock += it }, taskId = 124, diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(0, event.colorVerticalSwipeCount) + assertEquals(1, event.colorTrailingEmptyReadCount) + assertEquals(null, event.colorHorizontalIncomplete) + } + + @Test + fun `color discovery resets trailing empty rounds after nonempty discovery`() { + val colors = listOf("A色", "B色", "C色", "D色") + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = colors, + colorVerticalPages = listOf(colors.take(2), emptyList(), colors.takeLast(2)), + rowSize = 1, + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add) + .collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000)))) + + assertTrue(result.successful) + assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(3, event.colorVerticalSwipeCount) + assertEquals(0, event.colorTrailingEmptyReadCount) + } + + @Test + fun `color discovery stable horizontal rows are complete`() { + val events = mutableListOf() + var clock = 0L + + val result = PddProductDetailCollector( + FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(false, event.colorHorizontalIncomplete) + assertEquals(0, event.colorTrailingEmptyReadCount) + } + + @Test + fun `color discovery horizontal dispatch failure before movement stays unknown`() { + val events = mutableListOf() + val base = FakeCollectorDriver(colors = listOf("A色", "B色")) + val driver = object : PddCollectorDriver by base { + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean = + if (direction == SwipeDirection.LEFT) false else base.swipeSpec(direction, anchor) + } + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add) + .collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(0, event.horizontalSwipeCount) + assertEquals(null, event.colorHorizontalIncomplete) + } + + @Test + fun `color discovery successful horizontal dispatch alone does not prove required exploration`() { + val events = mutableListOf() + var clock = 0L + val limited = config().copy(limits = config().limits + mapOf("specHorizontalSwipes" to 1, "stableEdgeReads" to 2)) + + val result = PddProductDetailCollector( + FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add, + ).collect(GOODS_ID, rule(limited)) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(1, event.horizontalSwipeCount) + assertEquals(null, event.colorHorizontalIncomplete) + } + + @Test + fun `color discovery observed horizontal movement then failure is incomplete`() { + val colors = listOf("A色", "B色", "C色") + val events = mutableListOf() + val base = FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))) + var leftSwipes = 0 + val driver = object : PddCollectorDriver by base { + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { + if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false + return base.swipeSpec(direction, anchor) + } + } + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add) + .collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(1, event.horizontalSwipeCount) + assertEquals(true, event.colorHorizontalIncomplete) + } + + @Test + fun `color discovery uncertain revisit preserves proven incomplete horizontal row`() { + val colors = listOf("A色", "B色", "C色", "D色", "E色") + val events = mutableListOf() + val base = FakeCollectorDriver( + colors = colors, + colorPages = listOf(listOf("A色", "B色", "D色", "E色"), listOf("B色", "C色", "D色", "E色")), + rowSize = 2, + resetHorizontalPageOnVerticalSwipe = true, + ) + var leftSwipes = 0 + val driver = object : PddCollectorDriver by base { + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { + if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false + return base.swipeSpec(direction, anchor) + } + } + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add) + .collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertTrue(requireNotNull(result.payload).dimensions.first { it.key == "color" }.values.contains("C色")) + assertTrue(leftSwipes > 2) + // The first A/B visit moves then fails; later A/B visits fail before + // movement. Unknown new evidence must not erase that unresolved work. + assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete) + } + + @Test + fun `color discovery observed horizontal movement at budget is incomplete`() { + val colors = listOf("A色", "B色", "C色") + val events = mutableListOf() + var clock = 0L + val limited = config().copy(limits = config().limits + ("specHorizontalSwipes" to 1)) + + val result = PddProductDetailCollector( + FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))), + { clock }, { clock += it }, taskId = 124, diagnostic = events::add, + ).collect(GOODS_ID, rule(limited)) + + assertTrue(result.successful) + assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete) + } + + @Test + fun `color discovery disjoint horizontal reflow is unknown`() { + val colors = listOf("A色", "B色", "C色", "D色") + val events = mutableListOf() + var clock = 0L + + val result = PddProductDetailCollector( + FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))), + { clock }, { clock += it }, taskId = 124, diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY } + assertEquals(1, event.horizontalSwipeCount) + assertEquals(null, event.colorHorizontalIncomplete) + } + @Test fun `color discovery records only bounded aggregate evidence`() { val colors = listOf("A色", "B色", "C色", "D色") @@ -1857,6 +2097,7 @@ class PddProductDetailCollectorTest { sizes: List = listOf("S"), private val colorPages: List> = listOf(colors), private val colorVerticalPages: List>? = null, + private val resetHorizontalPageOnVerticalSwipe: Boolean = false, private val sizePages: List> = listOf(sizes), private val prices: Map = colors.associateWith { 1000L }, private val failedClicks: Set = emptySet(), @@ -1867,6 +2108,7 @@ class PddProductDetailCollectorTest { private val rowSize: Int = 4, private val hideSelectedColorOption: Boolean = false, private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false, + private val hideColorHeadingAfterFirstVerticalPage: Boolean = false, private val soldOutUntilPulls: Int = 0, private val startWithPanelClosed: Boolean = false, private val firstEntryOpensReview: Boolean = false, @@ -1986,7 +2228,7 @@ class PddProductDetailCollectorTest { ?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)] val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20) - if (!continuationPage && !panelCollapsedNow) { + if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) { nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll") } if (!continuationPage && !hideColorNow && !panelCollapsedNow) { @@ -2118,8 +2360,11 @@ class PddProductDetailCollectorTest { if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false when (direction) { SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex) - SwipeDirection.UP -> verticalPage = (verticalPage + 1) - .coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0)) + SwipeDirection.UP -> { + verticalPage = (verticalPage + 1) + .coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0)) + if (resetHorizontalPageOnVerticalSwipe) horizontalPage = 0 + } SwipeDirection.DOWN -> { verticalPage = (verticalPage - 1).coerceAtLeast(0) downSwipeCount++ diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt index da33a87..717c8a5 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt @@ -3,10 +3,112 @@ package cn.ilapage.goauto.agent.persistence import java.sql.Connection import java.sql.DriverManager import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test class AgentDiagnosticStoreMigrationTest { + @Test + fun freshV5DatabaseHasNullableColorDiscoveryOutcomeColumns() = withDatabase { db -> + db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) } + assertEquals(5, AgentDiagnosticSchema.VERSION) + assertV5Columns(db) + insertOldDiagnostic(db) + assertOldDiagnosticPreserved(db) + for (incomplete in listOf(0, 1)) { + db.createStatement().use { statement -> + statement.executeUpdate( + "UPDATE agent_diagnostic SET color_vertical_swipe_count=4, " + + "color_trailing_empty_read_count=2, color_horizontal_incomplete=$incomplete", + ) + statement.executeQuery( + "SELECT color_vertical_swipe_count, color_trailing_empty_read_count, " + + "color_horizontal_incomplete FROM agent_diagnostic", + ).use { row -> + assertTrue(row.next()) + assertEquals(4, row.getInt(1)) + assertEquals(2, row.getInt(2)) + assertEquals(incomplete, row.getInt(3)) + } + } + } + } + + @Test + fun everySupportedVersionUpgradesToV5WithoutChangingExistingRows() { + for (oldVersion in 1..4) withDatabase { db -> + createOldDatabase(db, oldVersion) + insertOldDiagnostic(db) + val existingTables = snapshotTables(db) + + migrate(db, oldVersion, 5) + + assertV5Columns(db) + assertOldDiagnosticPreserved(db) + existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) } + assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys)) + assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys)) + assertEquals(3, snapshotTables(db).size) + migrate(db, oldVersion, 5) + assertOldDiagnosticPreserved(db) + existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) } + } + } + + @Test + fun v4UpgradeAddsOnlyMissingColumnsAndKeepsExistingV5Values() = withDatabase { db -> + createOldDatabase(db, 4) + insertOldDiagnostic(db) + db.createStatement().use { + it.execute("ALTER TABLE agent_diagnostic ADD COLUMN color_vertical_swipe_count INTEGER") + it.executeUpdate("UPDATE agent_diagnostic SET color_vertical_swipe_count=3") + } + val statements = AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)) + assertEquals(2, statements.size) + assertTrue(statements.all { it.startsWith("ALTER TABLE agent_diagnostic ADD COLUMN ") }) + migrate(db, 4, 5) + assertV5Columns(db) + db.createStatement().use { statement -> + statement.executeQuery("SELECT color_vertical_swipe_count, color_trailing_empty_read_count, color_horizontal_incomplete FROM agent_diagnostic").use { + assertTrue(it.next()) + assertEquals(3, it.getInt(1)) + assertNull(it.getObject(2)) + assertNull(it.getObject(3)) + } + } + assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty()) + assertTrue(AgentDiagnosticSchema.migrationStatements(5, 5, columnNames(db)).isEmpty()) + assertTrue(AgentDiagnosticSchema.migrationStatements(4, 4, columnNames(db)).isEmpty()) + } + + @Test + fun v5UpgradeSurvivesDatabaseReopen() { + val file = java.io.File.createTempFile("goauto-diagnostic-migration-", ".db") + try { + Class.forName("org.sqlite.JDBC") + DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db -> + createOldDatabase(db, 4) + insertOldDiagnostic(db) + migrate(db, 4, 5) + db.createStatement().use { it.execute("PRAGMA user_version=5") } + } + DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db -> + assertV5Columns(db) + assertOldDiagnosticPreserved(db) + assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty()) + assertEquals(3, snapshotTables(db).size) + db.createStatement().use { statement -> + statement.executeQuery("PRAGMA user_version").use { + assertTrue(it.next()) + assertEquals(5, it.getInt(1)) + } + } + } + } finally { + check(file.delete()) { "Could not remove temporary migration test database" } + } + } + @Test fun v4AddsIndependentFailureSnapshotQueueWithoutChangingStructuredRows() = withDatabase { db -> db.createStatement().use { @@ -16,7 +118,6 @@ class AgentDiagnosticStoreMigrationTest { AgentDiagnosticSchema.migrationStatements(3, 4, columnNames(db)).forEach { sql -> db.createStatement().use { it.execute(sql) } } - assertEquals(4, AgentDiagnosticSchema.VERSION) for (table in listOf("purchase_failure_snapshot", "purchase_failure_snapshot_queue", "purchase_failure_snapshot_context")) { db.prepareStatement("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?").use { it.setString(1, table) @@ -137,6 +238,80 @@ class AgentDiagnosticStoreMigrationTest { assertEquals(1, rowCount(db)) } + private fun assertV5Columns(db: Connection) { + val actual = mutableSetOf() + db.createStatement().use { statement -> + statement.executeQuery("PRAGMA table_info(agent_diagnostic)").use { rows -> + while (rows.next()) { + val name = rows.getString("name") + if (name in V5_COLUMNS) { + actual.add(name) + assertEquals(name, "INTEGER", rows.getString("type")) + assertEquals(name, 0, rows.getInt("notnull")) + assertNull(name, rows.getObject("dflt_value")) + } + } + } + } + assertEquals(V5_COLUMNS, actual) + } + + private fun createOldDatabase(db: Connection, version: Int) { + db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) } + migrate(db, 1, version) + if (version >= 4) db.createStatement().use { + it.execute("INSERT INTO purchase_failure_snapshot VALUES ('attempt','test-origin',106,7,'spec_probe',1000,2000,'test','ready','{}',X'0102','test-fingerprint',2)") + it.execute("INSERT INTO purchase_failure_snapshot_queue VALUES ('attempt','test-fingerprint',1500,2)") + it.execute("INSERT INTO purchase_failure_snapshot_context VALUES ('attempt','test-origin','{}',1000)") + } + } + + private fun insertOldDiagnostic(db: Connection) { + db.createStatement().use { + it.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (106,'COLOR_DISCOVERY','COLOR_EDGE_REACHED',2,10,'old',1000)") + } + } + + private fun assertOldDiagnosticPreserved(db: Connection) { + assertEquals(1, rowCount(db)) + db.createStatement().use { statement -> + statement.executeQuery("SELECT id,task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic").use { + assertTrue(it.next()) + assertEquals(1L, it.getLong(1)) + assertEquals(106L, it.getLong(2)) + assertEquals("COLOR_DISCOVERY", it.getString(3)) + assertEquals("COLOR_EDGE_REACHED", it.getString(4)) + assertEquals(2, it.getInt(5)) + assertEquals(10L, it.getLong(6)) + assertEquals("old", it.getString(7)) + assertEquals(1000L, it.getLong(8)) + for (index in 9..11) assertNull(it.getObject(index)) + } + } + } + + private fun snapshotTables(db: Connection): Map>> = + db.createStatement().use { statement -> + statement.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'purchase_failure_snapshot%'").use { rows -> + buildList { while (rows.next()) add(rows.getString(1)) } + } + }.associateWith { tableRows(db, it) } + + private fun tableRows(db: Connection, table: String): List> = + db.createStatement().use { statement -> + statement.executeQuery("SELECT * FROM $table ORDER BY attempt_id").use { rows -> + buildList { + while (rows.next()) add((1..rows.metaData.columnCount).map { rows.getString(it) }) + } + } + } + + private fun migrate(db: Connection, oldVersion: Int, newVersion: Int) { + AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, columnNames(db)).forEach { sql -> + db.createStatement().use { it.execute(sql) } + } + } + private fun migrateV1ToV2(db: Connection) { AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql -> db.createStatement().use { it.execute(sql) } @@ -166,6 +341,7 @@ class AgentDiagnosticStoreMigrationTest { } private companion object { + val V5_COLUMNS = setOf("color_vertical_swipe_count", "color_trailing_empty_read_count", "color_horizontal_incomplete") val CREATE_V1_TABLE_SQL = """CREATE TABLE agent_diagnostic ( id INTEGER PRIMARY KEY AUTOINCREMENT,