From a49dc696e9ec58e2381bbb3b67cfc41bb0c0fbec Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 17:15:30 +0800 Subject: [PATCH] fix(android): guard spec probe clicks and persist attempt diagnostics (#361) --- android/app/build.gradle.kts | 4 +- .../agent/automation/CurrentPageCollection.kt | 3 + .../automation/GoAutoAccessibilityService.kt | 21 ++++- .../automation/PddProductDetailCollector.kt | 20 ++++- .../automation/PurchaseRehearsalExecutor.kt | 22 ++++- .../agent/automation/SizeAdviceClickPolicy.kt | 42 ++++++++++ .../agent/automation/SpecClickDiagnostic.kt | 32 +++++++ .../persistence/AgentDiagnosticSchema.kt | 18 +++- .../agent/persistence/AgentDiagnosticStore.kt | 23 ++++- .../agent/service/AgentForegroundService.kt | 21 ++++- .../agent/PddProductDetailCollectorTest.kt | 41 +++++++++ .../agent/PurchaseRehearsalExecutorTest.kt | 84 ++++++++++++++++--- .../automation/SizeAdviceClickPolicyTest.kt | 46 ++++++++++ .../automation/SpecClickDiagnosticTest.kt | 21 +++++ .../AgentDiagnosticStoreMigrationTest.kt | 35 ++++++++ 15 files changed, 406 insertions(+), 27 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3ded304..02a19d0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 77 - versionName = "0.9.64" + versionCode = 81 + versionName = "0.9.68" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt index f7c3046..e811634 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt @@ -339,6 +339,7 @@ class CurrentPageIdentityRunner( when (outcome.result) { FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一") + FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口") FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit } } @@ -366,6 +367,7 @@ class CurrentPageIdentityRunner( when (outcome.result) { FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "复制链接入口不唯一") + FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口") FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit } } @@ -528,6 +530,7 @@ class CurrentPageIdentityRunner( FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN + FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED } private data class SharePreparation( diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index cb7fafa..be6c6f4 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -162,7 +162,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto while (!target.isClickable) { target = target.parent ?: return false } - return target.performAction(AccessibilityNodeInfo.ACTION_CLICK) + return !sizeAdviceBlocked(target) && target.performAction(AccessibilityNodeInfo.ACTION_CLICK) } override fun input(node: UiNodeRef, value: String): Boolean { @@ -212,7 +212,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser || NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds || (node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false - return node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + return !sizeAdviceBlocked(node) && node.performAction(AccessibilityNodeInfo.ACTION_CLICK) } fun scrollBackfill(): Boolean { @@ -329,6 +329,15 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result + private fun sizeAdviceBlocked(node: AccessibilityNodeInfo): Boolean = + node.packageName?.toString() == "com.xunmeng.pinduoduo" && SizeAdviceClickPolicy.blocked( + node, + clickable = { it.isClickable }, + parent = { it.parent }, + children = { current -> (0 until current.childCount).mapNotNull(current::getChild) }, + labels = { listOfNotNull(it.text?.toString(), it.contentDescription?.toString()) }, + ) + override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE) val candidates = mutableListOf() @@ -356,6 +365,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto ) ancestorDepth++ } + if (sizeAdviceBlocked(node)) return FreshClickOutcome( + FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, candidates.size, ancestorDepth, + ) return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth) } else { @@ -392,6 +404,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto ) ancestorDepth++ } + if (sizeAdviceBlocked(node)) return FreshClickOutcome( + FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, ancestorDepth, + ) return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth) } else { @@ -414,6 +429,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED val bounds = Rect().also(candidates.single()::getBoundsInScreen) if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { return FreshActionResult.FAILED @@ -438,6 +454,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED val bounds = Rect().also(candidates.single()::getBoundsInScreen) val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom)) if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) { 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 d025052..bedd81b 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 @@ -38,7 +38,7 @@ data class UiSnapshot( val nodes: List, ) -enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED } +enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED, BLOCKED } enum class FreshClickReason { SUCCESS, @@ -47,6 +47,7 @@ enum class FreshClickReason { TARGET_AMBIGUOUS, NO_CLICKABLE_ANCESTOR, ACTION_CLICK_FALSE, + SIZE_ADVICE_BLOCKED, UNKNOWN, } @@ -1021,7 +1022,8 @@ class PddProductDetailCollector( while (opened == null) { val target = entry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口") val beforeClick = screenSignature(current) - val clickResult = driver.clickFresh(target) + val clickResult = clickSpecTarget(target, AgentDiagnosticStage.SPEC_ENTRY_CLICK) + if (clickResult == FreshActionResult.BLOCKED) return sizeAdviceFailure() trace( "spec-entry source=${entrySource ?: "unknown"} label=${target.label.take(40)} " + "class=${target.className} bounds=${target.bounds} result=$clickResult", @@ -1131,7 +1133,8 @@ class PddProductDetailCollector( opened = screen break } - when (driver.clickFresh(recoveryEntry)) { + when (clickSpecTarget(recoveryEntry, AgentDiagnosticStage.QUICK_CONFIRMATION_CLICK)) { + FreshActionResult.BLOCKED -> return sizeAdviceFailure() FreshActionResult.SUCCESS -> { quickConfirmationRecovered = true pause(100) @@ -1431,9 +1434,10 @@ class PddProductDetailCollector( } ?: return null attempted += value.text val beforeClick = screen - val clickResult = driver.clickFresh(value.node) + val clickResult = clickSpecTarget(value.node, AgentDiagnosticStage.COLOR_CLICK) trace("color-click color=${traceLabel(value.text)} result=$clickResult") when (clickResult) { + FreshActionResult.BLOCKED -> return sizeAdviceFailure() FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件") FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> { missing += "selection:${value.text}" @@ -1538,6 +1542,14 @@ class PddProductDetailCollector( return null } + private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口") + + private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult { + val outcome = driver.clickFreshDetailed(target) + if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) } + return outcome.result + } + private fun recordColorImage(reason: AgentDiagnosticReason) { if (taskId <= 0) return diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1)) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index a15dd6e..4fe0b51 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -1,5 +1,7 @@ package cn.ilapage.goauto.agent.automation +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage + import java.net.URI import java.net.URLDecoder @@ -106,6 +108,7 @@ class PurchaseRehearsalExecutor( private val stepChanged: (String) -> Unit = {}, private val panelDiagnostic: (String) -> Unit = {}, private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, + private val probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> }, ) { private var purchasePanelContext: PurchasePanelContext? = null @@ -393,6 +396,10 @@ class PurchaseRehearsalExecutor( } val beforeSignature = specActionSignature(screen) val click = driver.clickFreshDetailed(requireNotNull(target)) + if (input.phase == "spec_probe") runCatching { + probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_CLICK, requireNotNull(target), click) + } + if (click.result == FreshActionResult.BLOCKED) return sizeAdviceFailure() when (click.result) { // The parser already narrowed to a single semantic candidate; the // ambiguity here comes from the live tree matching that target more @@ -414,7 +421,14 @@ class PurchaseRehearsalExecutor( ) } - when (driver.tapSpecFresh(requireNotNull(target))) { + val gestureResult = driver.tapSpecFresh(requireNotNull(target)) + if (input.phase == "spec_probe") runCatching { + probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_GESTURE, requireNotNull(target), FreshClickOutcome( + gestureResult, if (gestureResult == FreshActionResult.BLOCKED) FreshClickReason.SIZE_ADVICE_BLOCKED else FreshClickReason.UNKNOWN, + )) + } + when (gestureResult) { + FreshActionResult.BLOCKED -> return sizeAdviceFailure() FreshActionResult.AMBIGUOUS -> return failure( SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]", @@ -605,6 +619,7 @@ class PurchaseRehearsalExecutor( val lookup = locateExactSpec(input, dimension, target) lookup.failure?.let { return it } val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node)) + if (outcome.result == FreshActionResult.BLOCKED) return sizeAdviceFailure() if (outcome.result == FreshActionResult.AMBIGUOUS) { return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件") } @@ -618,6 +633,7 @@ class PurchaseRehearsalExecutor( val refreshedLookup = locateExactSpec(input, dimension, target) refreshedLookup.failure?.let { return it } val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node)) + if (gesture == FreshActionResult.BLOCKED) return sizeAdviceFailure() if (gesture == FreshActionResult.AMBIGUOUS) { return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一") } @@ -1165,6 +1181,8 @@ class PurchaseRehearsalExecutor( private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { if (action.waitAfterMs > 0) pause(action.waitAfterMs) + // The collector owns probe scrolling. Preserve settling time and every purchase-phase action. + if (input.phase == "spec_probe" && action.type == PurchaseActionType.OPEN_SPEC_PANEL) return null action.swipeAfter?.let { swipe -> val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL val initialScreen = if (reveal) currentScreen(input) else null @@ -1207,6 +1225,8 @@ class PurchaseRehearsalExecutor( return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result) } + private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口") + private fun normalizedTarget(dimension: String, raw: String): String? { val trimmed = raw.trim() if (dimension != "size") return trimmed.takeIf(String::isNotBlank) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt new file mode 100644 index 0000000..d4a2237 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicy.kt @@ -0,0 +1,42 @@ +package cn.ilapage.goauto.agent.automation + +/** Only inspects the actual click receiver, never the whole page or unrelated siblings. */ +internal object SizeAdviceClickPolicy { + private val phrases = listOf( + "查看尺码建议", "查看尺碼建議", "查看尺寸建议", "查看尺寸建議", + "尺码助手", "尺碼助手", "尺寸助手", "智能选码", "智能選碼", "选码建议", "選碼建議", + ) + + fun containsAdvice(label: String): Boolean { + val compact = label.filterNot(Char::isWhitespace) + return phrases.any(compact::contains) + } + + fun blocked( + target: N, + clickable: (N) -> Boolean, + parent: (N) -> N?, + children: (N) -> List, + labels: (N) -> List, + ): Boolean { + var receiver = target + while (!clickable(receiver)) { + val ancestor = parent(receiver) + if (ancestor == null) { + // A gesture can still reach a non-clickable leaf. Inspect the original + // target, not the unrelated root subtree, if no receiver is exposed. + receiver = target + break + } + receiver = ancestor + } + val pending = ArrayDeque() + pending.add(receiver) + while (pending.isNotEmpty()) { + val node = pending.removeFirst() + if (labels(node).any(::containsAdvice)) return true + pending.addAll(children(node)) + } + return false + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt new file mode 100644 index 0000000..76e12de --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnostic.kt @@ -0,0 +1,32 @@ +package cn.ilapage.goauto.agent.automation + +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage + +/** No labels, bounds, selectors, or raw trace strings cross this boundary. */ +internal fun specClickDiagnostic( + taskId: Long, stage: AgentDiagnosticStage, target: SnapshotNode, outcome: FreshClickOutcome, +) = AgentDiagnosticEvent( + taskId = taskId, + stage = stage, + reason = when (outcome.reason) { + FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED + FreshClickReason.ROOT_UNAVAILABLE -> AgentDiagnosticReason.ROOT_UNAVAILABLE + FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR + else -> when (outcome.result) { + FreshActionResult.SUCCESS -> AgentDiagnosticReason.CLICK_SUCCESS + FreshActionResult.BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED + FreshActionResult.AMBIGUOUS -> AgentDiagnosticReason.TARGET_AMBIGUOUS + FreshActionResult.NOT_FOUND -> AgentDiagnosticReason.TARGET_NOT_FOUND + FreshActionResult.FAILED -> AgentDiagnosticReason.ACTION_CLICK_FALSE + } + }, + candidateCount = outcome.candidateCount, + targetClassName = target.className?.takeIf { it in setOf( + "android.widget.TextView", "android.widget.Button", "android.widget.LinearLayout", + "android.widget.FrameLayout", "android.view.View", "android.view.ViewGroup", + ) }, + targetClickable = target.clickable, + clickableAncestorDepth = outcome.clickableAncestorDepth, +) 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 fab4c66..3105726 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,12 @@ package cn.ilapage.goauto.agent.persistence internal object AgentDiagnosticSchema { - const val VERSION = 2 + const val VERSION = 3 + + val purchaseDiagnosticColumns = linkedMapOf( + "task_type" to "TEXT", "task_attempt_id" to "TEXT", "device_id" to "INTEGER", + "phase" to "TEXT", "rule_snapshot_hash" to "TEXT", + ) val colorDiagnosticColumns = linkedMapOf( "color_row_count" to "INTEGER", @@ -42,6 +47,11 @@ internal object AgentDiagnosticSchema { initial_selected_size_count INTEGER, selected_summary_present INTEGER, horizontal_swipe_count INTEGER, + task_type TEXT, + task_attempt_id TEXT, + device_id INTEGER, + phase TEXT, + rule_snapshot_hash TEXT, agent_version TEXT NOT NULL, created_at INTEGER NOT NULL )""".trimIndent() @@ -52,4 +62,10 @@ internal object AgentDiagnosticSchema { if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" } } + + fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set): List = + v2MigrationStatements(oldVersion, newVersion, existingColumns) + + 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() } 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 7570f93..f19d23d 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 @@ -7,6 +7,10 @@ import android.database.sqlite.SQLiteOpenHelper import cn.ilapage.goauto.agent.BuildConfig enum class AgentDiagnosticStage { + SPEC_ENTRY_CLICK, + SPEC_ENTRY_GESTURE, + QUICK_CONFIRMATION_CLICK, + COLOR_CLICK, DETAIL_ENTRY, SPEC_PANEL_ENTRY, COLOR_DISCOVERY, @@ -20,6 +24,7 @@ enum class AgentDiagnosticStage { } enum class AgentDiagnosticReason { + SIZE_ADVICE_CLICK_BLOCKED, DETAIL_ENTRY_MATCHED, SPEC_PANEL_OPENED, SPEC_PANEL_RECOVERED, @@ -104,6 +109,11 @@ data class AgentDiagnosticEvent( val selectedSummaryPresent: Boolean? = null, val horizontalSwipeCount: Int? = null, val createdAt: Long = System.currentTimeMillis(), + val taskType: String = "collection", + val taskAttemptId: String? = null, + val deviceId: Long? = null, + val phase: String? = null, + val ruleSnapshotHash: String? = null, ) class SafeAgentDiagnosticRecorder( @@ -130,7 +140,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { val existing = columnNames(db) - AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL) + AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL) } private fun columnNames(db: SQLiteDatabase): Set = @@ -144,11 +154,22 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS @Synchronized fun record(event: AgentDiagnosticEvent) { require(event.taskId > 0) + require(event.taskType in setOf("collection", "purchase")) + if (event.taskType == "purchase") { + require(event.taskAttemptId?.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")) == true) + require((event.deviceId ?: 0) > 0 && event.phase in setOf("spec_probe", "purchase")) + require(event.ruleSnapshotHash?.matches(Regex("[0-9a-fA-F]{64}")) == true) + } val db = writableDatabase db.beginTransaction() try { val values = ContentValues().apply { put("task_id", event.taskId) + put("task_type", event.taskType) + put("task_attempt_id", event.taskAttemptId) + event.deviceId?.let { put("device_id", it) } + put("phase", event.phase) + put("rule_snapshot_hash", event.ruleSnapshotHash) put("stage", event.stage.name) put("reason", event.reason.name) put("attempt", event.attempt.coerceAtLeast(0)) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index f8fba2b..5febd8d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -22,6 +22,7 @@ import cn.ilapage.goauto.agent.ClipboardRelayActivity import cn.ilapage.goauto.agent.MainActivity import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.automation.CollectionAssembler +import cn.ilapage.goauto.agent.automation.specClickDiagnostic import cn.ilapage.goauto.agent.automation.AgentCapabilities import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult @@ -589,6 +590,13 @@ class AgentForegroundService : Service() { val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64) purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash) val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练" + val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull() + val probeDiagnostic: (AgentDiagnosticEvent) -> Unit = { event -> + diagnosticRecorder.record(event.copy( + taskId = task.taskId, taskType = "purchase", taskAttemptId = task.taskAttemptId, + deviceId = diagnosticDeviceId, phase = task.phase, ruleSnapshotHash = task.ruleSnapshotHash, + )) + } stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true) updateNotification("$taskLabel #${task.taskId}") @@ -612,7 +620,10 @@ class AgentForegroundService : Service() { PurchaseRehearsalExecutor( driver = accessibility, openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, - probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) }, + probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) }, + probeClickDiagnostic = { stage, target, outcome -> + probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome)) + }, stepChanged = { step -> lastStep.set(step) purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) @@ -683,7 +694,10 @@ class AgentForegroundService : Service() { } } - private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? { + private fun collectPurchaseProbe( + accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule, + diagnostic: (AgentDiagnosticEvent) -> Unit, + ): String? { val snapshot = accessibility.capture() val activity = snapshot.activityName ?: return null val collector = PurchaseRehearsalExecutor.collectorForProbe(purchaseRule) @@ -694,7 +708,8 @@ class AgentForegroundService : Service() { pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()), collector = collector, ) - val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule) + val result = PddProductDetailCollector(accessibility, taskId = task.taskId, diagnostic = diagnostic) + .collect(task.pddGoodsId, rule) // #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not // collapse into a generic "spec probe failed" outcome. Carry the // real code/message through the opaque probeSpecs callback so 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 e91e37b..3e39802 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 @@ -27,6 +27,47 @@ import org.junit.Assert.assertTrue import org.junit.Test class PddProductDetailCollectorTest { + @Test + fun `blocked color records ancestor evidence and never clicks a different color`() { + val base = FakeCollectorDriver(colors = listOf("红色", "蓝色")) + var attempts = 0 + val driver = object : PddCollectorDriver by base { + override fun clickFreshDetailed(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome { + attempts++ + return cn.ilapage.goauto.agent.automation.FreshClickOutcome( + FreshActionResult.BLOCKED, cn.ilapage.goauto.agent.automation.FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2, + ) + } + } + var clock = 0L + val events = mutableListOf() + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add) + .collect(GOODS_ID, rule()) + assertFalse(result.successful) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", result.code) + assertEquals(1, attempts) + assertEquals(0, base.clickCount) + val event = events.single { it.stage == AgentDiagnosticStage.COLOR_CLICK } + assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason) + assertEquals(2, event.clickableAncestorDepth) + } + + @Test + fun `probe collector records clicks and discovers offscreen weight labelled sizes`() { + val driver = FakeCollectorDriver( + colors = listOf("黑色"), + sizePages = listOf(listOf("M 建议80-100斤"), listOf("4XL 建议160-180斤")), + ) + var clock = 0L + val events = mutableListOf() + val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add) + .collect(GOODS_ID, rule()) + assertTrue(result.successful) + assertEquals(listOf("M 建议80-100斤", "4XL 建议160-180斤"), result.payload!!.dimensions.first { it.key == "size" }.values) + assertTrue(driver.swipes.any { it.first == SwipeDirection.UP }) + assertTrue(events.any { it.stage == AgentDiagnosticStage.COLOR_CLICK && it.reason == AgentDiagnosticReason.CLICK_SUCCESS }) + } + private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( node("root", "", 0, 0, 1080, 2376), node("close", "关闭", 970, 270, 1050, 350, clickable = true), diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 683b444..43de665 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -24,6 +24,62 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseRehearsalExecutorTest { + @Test + fun `probe skips post open swipes but preserves wait and records click`() { + val driver = FakePurchaseDriver() + val pauses = mutableListOf() + val clicks = mutableListOf() + val configured = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":1234") + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, + pause = pauses::add, probeClickDiagnostic = { _, _, click -> clicks += click }, + ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported) + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(0, driver.swipeCount) + assertEquals(1, pauses.count { it == 1234L }) + assertTrue(clicks.any { it.result == FreshActionResult.SUCCESS }) + } + + @Test + fun `blocked entry stops without gesture or probe`() { + val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode) + assertEquals(0, driver.specTapCount) + assertEquals(0, probes) + } + + @Test + fun `blocked size stops without gesture retry`() { + val driver = FakePurchaseDriver(forcedSizeClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode) + assertEquals(0, driver.specTapCount) + assertEquals(1, driver.sizeClickCount) + } + + @Test + fun `blocked gesture stops without another click`() { + val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.ACTION_CLICK_FALSE, specTapResult = FreshActionResult.BLOCKED) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode) + assertEquals(1, driver.specTapCount) + assertEquals(0, probes) + } + + @Test + fun `purchase still fails required post open swipe`() { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertEquals(1, driver.swipeCount) + } + @Test fun `color selection then single size heading completes without selecting color again`() { val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true) @@ -778,7 +834,7 @@ class PurchaseRehearsalExecutorTest { } @Test - fun `open spec reveal skips missing scroll target regardless of panel classification`() { + fun `probe skips reveal regardless of panel classification`() { val driver = FakePurchaseDriver( panelOverride = SpecPanelFixtures.liveShapedSheet(), purchaseSwipeSucceeds = false, @@ -793,11 +849,11 @@ class PurchaseRehearsalExecutorTest { assertEquals(outcome.message, "spec_probe_completed", outcome.resultType) assertEquals(1, probes) assertEquals(0, driver.swipeCount) - assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") }) + assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") }) } @Test - fun `open spec reveal with a scroll target still fails on gesture failure`() { + fun `probe does not invoke failing reveal even with a scroll target`() { val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) var probes = 0 val diagnostics = mutableListOf() @@ -806,11 +862,10 @@ class PurchaseRehearsalExecutorTest { pause = {}, panelDiagnostic = diagnostics::add, ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("RULE_ACTION_FAILED", outcome.errorCode) - assertEquals(0, probes) - assertEquals(1, driver.swipeCount) - assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed")) - assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") }) + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(1, probes) + assertEquals(0, driver.swipeCount) + assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") }) assertFalse(outcome.message.contains("黑色")) assertFalse(outcome.message.contains(input().goodsId)) } @@ -822,7 +877,7 @@ class PurchaseRehearsalExecutorTest { )) var probes = 0 val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) - .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("RULE_ACTION_FAILED", outcome.errorCode) assertEquals(0, probes) @@ -837,9 +892,9 @@ class PurchaseRehearsalExecutorTest { )) val pauses = mutableListOf() val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) - .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("rehearsal_completed", outcome.resultType) assertEquals(1, driver.swipeCount) assertEquals(1, pauses.count { it == 1000L }) } @@ -849,9 +904,9 @@ class PurchaseRehearsalExecutorTest { val driver = FakePurchaseDriver() val pauses = mutableListOf() val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) - .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("rehearsal_completed", outcome.resultType) assertEquals(2, driver.swipeCount) assertEquals(1, pauses.count { it == 1000L }) } @@ -1597,6 +1652,7 @@ class PurchaseRehearsalExecutorTest { FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS + FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED } return FreshClickOutcome(result, forcedEntryClickReason) } @@ -1607,6 +1663,7 @@ class PurchaseRehearsalExecutorTest { FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS + FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED } return FreshClickOutcome(result, forcedSizeClickReason) } @@ -1616,6 +1673,7 @@ class PurchaseRehearsalExecutorTest { FreshActionResult.NOT_FOUND -> FreshClickReason.TARGET_NOT_FOUND FreshActionResult.AMBIGUOUS -> FreshClickReason.TARGET_AMBIGUOUS FreshActionResult.FAILED -> FreshClickReason.ACTION_CLICK_FALSE + FreshActionResult.BLOCKED -> FreshClickReason.SIZE_ADVICE_BLOCKED } return FreshClickOutcome(result, reason) } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt new file mode 100644 index 0000000..41edcaf --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SizeAdviceClickPolicyTest.kt @@ -0,0 +1,46 @@ +package cn.ilapage.goauto.agent.automation + +import org.junit.Assert.* +import org.junit.Test + +class SizeAdviceClickPolicyTest { + private class Node(val label: String, val clickable: Boolean, val description: String = "") { + var parent: Node? = null + val children = mutableListOf() + fun add(node: Node): Node { children += node; node.parent = this; return node } + } + private fun blocked(node: Node) = SizeAdviceClickPolicy.blocked( + node, { it.clickable }, { it.parent }, { it.children }, { listOf(it.label, it.description) }, + ) + + @Test fun sameRowAdviceBlocksDirectReceiverAndLeafGesture() { + val row = Node("", true) + val title = row.add(Node("尺码", false)) + row.add(Node("查看尺码建议", false)) + assertTrue(blocked(row)) + assertTrue(blocked(title)) + } + + @Test fun adviceElsewhereDoesNotBlockNormalOptionOrWeightDescription() { + val panel = Node("", true) + panel.add(Node("查看尺码建议", true)) + val option = panel.add(Node("M 建议80-100斤", true)) + val leaf = option.add(Node("M 建议80-100斤", false)) + assertFalse(blocked(option)) + assertFalse(blocked(leaf)) + } + + @Test fun traditionalAndContentDescriptionAreProtectedButPlainAdviceIsNot() { + assertTrue(blocked(Node("", true, "查看尺碼建議"))) + assertTrue(blocked(Node("查 看 尺 寸 建 议", true))) + assertTrue(blocked(Node("智能選碼", true))) + assertFalse(blocked(Node("XL 建議55.5-60.0公斤穿", true))) + assertFalse(blocked(Node("尺码", true))) + } + + @Test fun nonClickableAdviceLeafWithoutClickableAncestorCannotBypassGestureGuard() { + val root = Node("", false) + assertTrue(blocked(root.add(Node("查看尺码建议", false)))) + assertFalse(blocked(root.add(Node("M 建议80-100斤", false)))) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt new file mode 100644 index 0000000..267424f --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/SpecClickDiagnosticTest.kt @@ -0,0 +1,21 @@ +package cn.ilapage.goauto.agent.automation + +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage +import org.junit.Assert.* +import org.junit.Test + +class SpecClickDiagnosticTest { + @Test fun recordsOnlyStructuralFieldsIncludingBlockedAncestor() { + val node = SnapshotNode("private-path", null, "private-label", "private-description", "private-id", + "private-class", NodeBounds(1,2,3,4), false, false, false, false, true, true) + val event = specClickDiagnostic(12, AgentDiagnosticStage.COLOR_CLICK, node, + FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2)) + assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason) + assertEquals(1, event.candidateCount) + assertEquals(2, event.clickableAncestorDepth) + assertNull(event.targetClassName) + assertFalse(event.toString().contains("private")) + assertEquals("collection", event.taskType) + } +} 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 710c9fd..1115c3e 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 @@ -7,6 +7,41 @@ import org.junit.Assert.assertTrue import org.junit.Test class AgentDiagnosticStoreMigrationTest { + @Test + fun upgradeFromV1OrV2PreservesOldRowsAndSeparatesPurchaseAttempts() { + for (oldVersion in listOf(1, 2)) withDatabase { db -> + db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) } + if (oldVersion == 2) migrateV1ToV2(db) + db.createStatement().use { it.execute("INSERT INTO agent_diagnostic " + + "(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) " + + "VALUES (1,'DETAIL_ENTRY','DETAIL_ENTRY_MATCHED',2,10,'old',1000)") } + AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).forEach { sql -> + db.createStatement().use { it.execute(sql) } + } + assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys)) + assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys)) + assertTrue(AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).isEmpty()) + db.createStatement().use { statement -> + statement.executeQuery("SELECT task_type,task_attempt_id,attempt FROM agent_diagnostic").use { + assertTrue(it.next()); assertEquals(null, it.getString(1)); assertEquals(null, it.getString(2)); assertEquals(2, it.getInt(3)) + } + } + for (attemptId in listOf("11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222")) { + db.prepareStatement("INSERT INTO agent_diagnostic " + + "(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,task_type,task_attempt_id,device_id,phase,rule_snapshot_hash) " + + "VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',3,10,'new',2000,'purchase',?,7,'spec_probe',?)").use { + it.setString(1, attemptId); it.setString(2, "a".repeat(64)); assertEquals(1, it.executeUpdate()) + } + } + assertEquals(3, rowCount(db)) + db.createStatement().use { statement -> + statement.executeQuery("SELECT COUNT(DISTINCT task_attempt_id) FROM agent_diagnostic WHERE task_type='purchase' AND task_id=1").use { + assertTrue(it.next()); assertEquals(2, it.getInt(1)) + } + } + } + } + @Test fun freshDatabaseCreatesV2ColumnsAndAcceptsSingleColorDiagnosticRecord() = withDatabase { db -> db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }