From 51265681c218783bd7c0122be583f47b29dada96 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 15 Sep 2026 10:39:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E5=9B=BE=E6=90=9C=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=B5=84=E4=BA=A7=E5=87=86=E5=A4=87=E3=80=81=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=88=A4=E6=8D=AE=E4=B8=8E=E8=B0=83=E5=BA=A6=E5=9E=AB?= =?UTF-8?q?=E5=BA=95=20#277?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PinduoduoImageSearchAssetStore 补全 sha256/大小/mediaType 校验之外的四点: 写入前清理自身历史图片、最小边不足 960 时放大、写入后等待媒体库索引并做 JPEG 头尾标记校验、isMostRecent() 判定自己是否为相册最新一张(不识别图片 内容,只保证相册第一张即是本次图搜参考图)。纯逻辑部分拆到 ImageSearchAssetPolicy,JVM 单测覆盖校验/放大边界。 - 新增 PinduoduoImageSearchCriteria:图搜入口页/结果页/"再试一次"弹窗/四列 最近项目网格的纯文本判据,从同作者已在真机走通的旧项目移植常量,注释标注 "待真机核对",集中存放便于后续按 dump 结果调整。 - TaskDispatchPolicy.decide 新增可选参数 hasImageSearchWaiting 与 CHECK_IMAGE_SEARCH 分支,采购 > 采集 > 图搜;默认值保证既有四条分支行为 逐字节不变,回归测试见 TaskDispatchPolicyTest。 - AgentForegroundService 的 image_search 分支:真实校验相册权限并给出 IMAGE_SEARCH_PERMISSION_REQUIRED 等人话失败文案;点击入口/选图/等待结果页/ 打开候选的真实自动化状态机本次未接入执行路径,原因见工单——真机 dump 无 障碍树前置门禁未执行,移植的选择器常量未经核对,不贸然接线。 - TaskHistoryFragment 采集列表行为 source == "image_search" 的任务加一行 "来源:图搜自动匹配" 标记(#279),不改动既有 collection 布尔分叉结构。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../automation/PinduoduoImageSearchAsset.kt | 131 +++++++++++++++++- .../PinduoduoImageSearchCriteria.kt | 67 +++++++++ .../agent/service/AgentForegroundService.kt | 28 +++- .../goauto/agent/service/AgentStateStore.kt | 21 ++- .../goauto/agent/ui/TaskHistoryFragment.kt | 5 + .../automation/ImageSearchAssetPolicyTest.kt | 127 +++++++++++++++++ .../PinduoduoImageSearchCriteriaTest.kt | 64 +++++++++ .../agent/service/TaskDispatchPolicyTest.kt | 84 +++++++++++ 8 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/automation/ImageSearchAssetPolicyTest.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteriaTest.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/service/TaskDispatchPolicyTest.kt diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAsset.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAsset.kt index 007c21c..edf2d35 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAsset.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchAsset.kt @@ -1,24 +1,66 @@ package cn.ilapage.goauto.agent.automation +import android.content.ContentResolver +import android.content.ContentUris import android.content.ContentValues import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.os.Build +import android.os.Bundle import android.provider.MediaStore import cn.ilapage.goauto.agent.network.ImageSearchImage +import java.io.ByteArrayOutputStream import java.security.MessageDigest import java.util.UUID data class PreparedImageSearchAsset(val uri: Uri) +/** + * 图搜参考图的纯校验与缩放决策逻辑(#277),与 Android 框架解耦以便 JVM 单测覆盖。 + * MediaStore 读写、真机相册顺序等只能真机验证,见 [PinduoduoImageSearchAssetStore]。 + */ object ImageSearchAssetPolicy { - private const val MAX_BYTES = 10 * 1024 * 1024 + const val MAX_BYTES = 20 * 1024 * 1024 + const val MIN_SEARCH_IMAGE_EDGE = 960 + const val MAX_SEARCH_IMAGE_EDGE = 4_096 + const val SEARCH_JPEG_QUALITY = 95 + fun valid(bytes: ByteArray, reference: ImageSearchImage): Boolean { if (bytes.isEmpty() || bytes.size.toLong() != reference.sizeBytes || bytes.size > MAX_BYTES) return false if (reference.mediaType != "image/jpeg" && reference.mediaType != "image/png") return false + if (reference.mediaType == "image/jpeg" && !hasJpegMarkers(bytes)) return false return sha256(bytes) == reference.sha256.lowercase() } - fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + + fun hasJpegMarkers(bytes: ByteArray): Boolean = + bytes.size >= 4 && + bytes[0] == 0xff.toByte() && + bytes[1] == 0xd8.toByte() && + bytes[bytes.size - 2] == 0xff.toByte() && + bytes[bytes.size - 1] == 0xd9.toByte() + + fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + + /** + * 目标缩放尺寸,纯整数运算,便于单测覆盖临界值。 + * 已经不小于 [MIN_SEARCH_IMAGE_EDGE] 时返回 null(原图直接使用)。 + * 放大后超过 [MAX_SEARCH_IMAGE_EDGE] 时也返回 null(放弃放大,交由调用方决定是否使用原图或失败)。 + */ + fun targetScaledSize(width: Int, height: Int): Pair? { + if (width <= 0 || height <= 0) return null + if (width >= MIN_SEARCH_IMAGE_EDGE && height >= MIN_SEARCH_IMAGE_EDGE) return null + val scale = maxOf( + MIN_SEARCH_IMAGE_EDGE.toDouble() / width, + MIN_SEARCH_IMAGE_EDGE.toDouble() / height, + ) + val targetWidth = (width * scale).toInt() + val targetHeight = (height * scale).toInt() + if (targetWidth > MAX_SEARCH_IMAGE_EDGE || targetHeight > MAX_SEARCH_IMAGE_EDGE) return null + return targetWidth to targetHeight + } } class PinduoduoImageSearchAssetStore(context: Context) { @@ -26,19 +68,94 @@ class PinduoduoImageSearchAssetStore(context: Context) { fun prepare(bytes: ByteArray, reference: ImageSearchImage): PreparedImageSearchAsset? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !ImageSearchAssetPolicy.valid(bytes, reference)) return null + deleteStalePreparedImages() + val searchBytes = prepareSearchCopy(bytes) ?: return null val values = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, "goauto-search-${UUID.randomUUID()}.jpg") + put(MediaStore.Images.Media.DISPLAY_NAME, "$DISPLAY_NAME_PREFIX${UUID.randomUUID()}.jpg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/GoAutoSearch") + put(MediaStore.Images.Media.RELATIVE_PATH, MEDIA_DIRECTORY) put(MediaStore.Images.Media.IS_PENDING, 1) } val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null return try { - resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable") + val wrote = resolver.openOutputStream(uri)?.use { it.write(searchBytes) } != null + if (!wrote) { + resolver.delete(uri, null, null) + return null + } resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) + resolver.notifyChange(uri, null) + Thread.sleep(MEDIA_INDEX_SETTLE_MILLIS) PreparedImageSearchAsset(uri) - } catch (_: Exception) { resolver.delete(uri, null, null); null } + } catch (_: Exception) { + resolver.delete(uri, null, null) + null + } } - fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, null, null) } } + /** + * 本方案不做图像识别,只保证自己写入的图片是相册中最新的一张(按 DATE_ADDED/_ID 降序), + * 从而在 PDD 图搜页"最近项目"网格里稳定命中第一格。真机验证未执行。 + */ + fun isMostRecent(asset: PreparedImageSearchAsset): Boolean { + val args = Bundle().apply { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Images.Media.MIME_TYPE} = ?") + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf("image/jpeg")) + putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf(MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media._ID), + ) + putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING) + putInt(ContentResolver.QUERY_ARG_LIMIT, 1) + } + return runCatching { + resolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Images.Media._ID), args, null) + ?.use { cursor -> + if (!cursor.moveToFirst()) return@use false + val latestUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cursor.getLong(0)) + ContentUris.parseId(latestUri) == ContentUris.parseId(asset.uri) + } == true + }.getOrDefault(false) + } + + fun delete(asset: PreparedImageSearchAsset?) { + asset ?: return + runCatching { resolver.delete(asset.uri, null, null) } + } + + private fun deleteStalePreparedImages() { + runCatching { + resolver.delete( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + "${MediaStore.Images.Media.RELATIVE_PATH} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} LIKE ?", + arrayOf("$MEDIA_DIRECTORY/", "$DISPLAY_NAME_PREFIX%.jpg"), + ) + } + } + + private fun prepareSearchCopy(source: ByteArray): ByteArray? { + val bitmap = BitmapFactory.decodeByteArray(source, 0, source.size) ?: return null + val target = ImageSearchAssetPolicy.targetScaledSize(bitmap.width, bitmap.height) + if (target == null) { + bitmap.recycle() + return source + } + val (targetWidth, targetHeight) = target + val scaled = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true) + if (scaled !== bitmap) bitmap.recycle() + return try { + ByteArrayOutputStream().use { output -> + if (!scaled.compress(Bitmap.CompressFormat.JPEG, ImageSearchAssetPolicy.SEARCH_JPEG_QUALITY, output)) return null + output.toByteArray() + } + } finally { + scaled.recycle() + } + } + + private companion object { + const val MEDIA_DIRECTORY = "Pictures/GoAutoSearch" + const val DISPLAY_NAME_PREFIX = "goauto-search-" + const val MEDIA_INDEX_SETTLE_MILLIS = 750L + } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt new file mode 100644 index 0000000..f930344 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteria.kt @@ -0,0 +1,67 @@ +package cn.ilapage.goauto.agent.automation + +/** + * PDD 图搜相关页面判据的纯函数实现(#277)。 + * + * 这里的常量(文案、contentDescription)全部移植自同作者另一个已在真机走通图搜流程的 + * 项目(本机 D:\chengma\cmroubao_old,android-buyer 模块),**尚未在本项目的真机上重新核对**。 + * 本工单前置的真机 dump 无障碍树步骤未执行,因此这些常量只是移植猜测的最佳起点, + * 如与当前 PDD 版本 UI 不符,需要用真机 dump 结果更新本文件。 + * + * 所有函数只接受简单文本/几何输入,不依赖 AccessibilityNodeInfo,方便 JVM 单测覆盖; + * 真正从无障碍树里抽取这些输入的代码在 [PinduoduoImageSearchAutomation] 中,需真机验证。 + */ +object PinduoduoImageSearchCriteria { + + /** 首页/搜索结果页上,图搜入口按钮的 contentDescription。要求唯一匹配后才可点击。 */ + const val CAMERA_ENTRY_CONTENT_DESCRIPTION = "拍照搜索" + + /** "再试一次"弹窗上取消按钮文案。 */ + const val RETRY_DIALOG_CANCEL_TEXT = "取消" + const val RETRY_DIALOG_CONFIRM_TEXT = "再试一次" + const val RETRY_DIALOG_HINT_TEXT = "请对准商品或码,保持手机稳定" + + const val RECENT_ITEMS_LABEL = "最近项目" + + private const val GRID_COLUMNS = 4 + private const val GRID_WIDTH_TOLERANCE_PX = 24 + + /** 图搜首页(选择"最近项目"/拍照)判据:需同时出现相册、最近搜索、历史浏览等文案,且有拍照引导。 */ + fun isImageSearchEntryPage(texts: Collection): Boolean { + val hasAlbum = texts.any { it.contains("我的相册") } + val hasRecentSearch = texts.any { it.contains("最近搜索") } + val hasHistory = texts.any { it.contains("历史浏览") } + val hasCameraHint = texts.any { + it.contains("点击拍照") || it.contains("开启相机权限") || it.contains("即可进行自动识别") + } + return hasAlbum && hasRecentSearch && hasHistory && hasCameraHint + } + + /** 图搜结果页判据:出现"搜图片同款"标题,且排序/筛选控件数量达到阈值。 */ + fun isImageSearchResultsPage(texts: Collection, sortControlCount: Int): Boolean = + texts.any { it.contains("搜图片同款") } && sortControlCount >= 3 + + /** "再试一次"弹窗判据。 */ + fun isRetryDialog(texts: Collection): Boolean { + val hasHint = texts.any { it.contains(RETRY_DIALOG_HINT_TEXT) } + val hasCancel = texts.any { it == RETRY_DIALOG_CANCEL_TEXT } + val hasRetry = texts.any { it == RETRY_DIALOG_CONFIRM_TEXT } + return hasHint && hasCancel && hasRetry + } + + data class GridCandidate(val top: Int, val left: Int, val width: Int, val height: Int) + + /** + * 在"最近项目"标签下方的候选矩形里,挑出 4 列等宽网格(宽度 * 4 约等于屏宽,容差 + * [GRID_WIDTH_TOLERANCE_PX]px;高度 >= 宽度/2)里最靠左上的一个(先按 top 再按 left 排序)。 + * 找不到符合条件的候选时返回 null。 + */ + fun firstRecentImageGridCell(candidates: List, screenWidth: Int): GridCandidate? { + val fourColumns = candidates.filter { cell -> + cell.width > 0 && + cell.height >= cell.width / 2 && + kotlin.math.abs(cell.width * GRID_COLUMNS - screenWidth) <= GRID_WIDTH_TOLERANCE_PX + } + return fourColumns.sortedWith(compareBy({ it.top }, { it.left })).firstOrNull() + } +} 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 00b8aa2..429858f 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 @@ -276,7 +276,7 @@ class AgentForegroundService : Service() { evaluateIdleReturn() return "$MANUAL_COLLECTION_COOLDOWN_PREFIX${CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)}" } - TaskDispatchDecision.CHECK_COLLECTION -> Unit + TaskDispatchDecision.CHECK_COLLECTION, TaskDispatchDecision.CHECK_IMAGE_SEARCH -> Unit } val task = api.nextTask(token) if (task == null) { @@ -414,6 +414,16 @@ class AgentForegroundService : Service() { }) } + /** #277:图搜任务失败码到人话文案的映射,采购员看得懂即可,不暴露内部状态机细节。 */ + private fun imageSearchUserMessage(code: String): String = when (code) { + "IMAGE_SEARCH_PERMISSION_REQUIRED" -> "未授予相册权限,无法准备图搜参考图,请到“状态”页开启相册权限后重试。" + "IMAGE_SEARCH_ENTRY_NOT_FOUND" -> "打不开拍照搜索入口,请检查 PDD App 版本或手动确认后重试。" + "IMAGE_SEARCH_NO_CANDIDATES" -> "没有搜到相似商品,请确认参考图清晰或换一张图后重试。" + "IMAGE_SEARCH_ASSET_INVALID" -> "图片准备失败,请重新发起图搜任务。" + "IMAGE_SEARCH_AUTOMATION_UNAVAILABLE" -> "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务。" + else -> "图搜任务失败,请稍后重试。" + } + private fun currentPageUserMessage(code: String, fallback: String): String = when (code) { "DEVICE_BUSY" -> "设备正在执行任务,请稍后再试。" "AGENT_MANUAL_RULE_NOT_CONFIGURED" -> "请先配置 Agent 手动采集规则。" @@ -709,7 +719,21 @@ class AgentForegroundService : Service() { if (!collection.successful) throw TaskFailure(collection.code, collection.message) CollectionExecution(requireNotNull(collection.payload), collection.colorImages) } else if (task.source == "image_search") { - throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务") + // #277:相册权限是图搜可执行的前置条件,这里可以真实校验并给出明确失败原因。 + // 图搜入口点击、结果页等待、候选打开等状态机(PinduoduoImageSearchAutomation) + // 在本次改动中尚未接入到这条执行路径——原因见工单:真机 dump 无障碍树的前置 + // 门禁未执行,移植的选择器常量(见 PinduoduoImageSearchCriteria)未经真机核对, + // 贸然接线存在把未验证的点击序列跑到用户真机 PDD 上的风险。下面四个错误码是为 + // 后续接线预留的人话文案,图片准备失败已经是真实校验(IMAGE_SEARCH_ASSET_INVALID)。 + if (cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.current(this) == + cn.ilapage.goauto.agent.ui.MediaPermissionReadiness.NOT_GRANTED + ) { + throw TaskFailure("IMAGE_SEARCH_PERMISSION_REQUIRED", imageSearchUserMessage("IMAGE_SEARCH_PERMISSION_REQUIRED")) + } + throw TaskFailure( + "IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", + imageSearchUserMessage("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE"), + ) } else if (rule.schemaVersion == 2) { val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) } val collection = PddDetailEntryRunner( diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt index 047361c..123289d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt @@ -102,13 +102,32 @@ internal enum class TaskDispatchDecision { WAIT_FOR_PURCHASE_MATCH, WAIT_FOR_COLLECTION_COOLDOWN, CHECK_COLLECTION, + CHECK_IMAGE_SEARCH, } +/** + * 调度优先级:采购 > 采集 > 图搜(#277)。 + * + * [hasImageSearchWaiting] 默认 false,行为与新增前逐字节一致(见 + * `TaskDispatchPolicyTest` 的既有四路径回归用例);只有在没有待处理采购、没有 + * 采集冷却,且调用方明确表示"本轮没有普通采集任务、但有排队的图搜任务"时才会 + * 返回 [TaskDispatchDecision.CHECK_IMAGE_SEARCH]。普通采集任务的检查 + * ([TaskDispatchDecision.CHECK_COLLECTION])永远排在图搜之前。 + */ internal object TaskDispatchPolicy { - fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when { + fun decide( + purchaseStatus: String?, + collectionCooldownActive: Boolean, + hasImageSearchWaiting: Boolean = false, + ): TaskDispatchDecision = when { purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN + // 采集 > 图搜:只有在没有普通采集队列(调用方通过 collectionCooldownActive=false + // 且明确传入 hasImageSearchWaiting=true 表达"这轮只有图搜任务在排队")时才路由到 + // CHECK_IMAGE_SEARCH;否则维持原有 CHECK_COLLECTION 语义不变,服务端 nextTask() + // 仍然是先查普通采集,查无结果调用方再自行查图搜队列。 + hasImageSearchWaiting -> TaskDispatchDecision.CHECK_IMAGE_SEARCH else -> TaskDispatchDecision.CHECK_COLLECTION } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt index d8b3f4e..440e9ab 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -427,6 +427,11 @@ class TaskHistoryFragment : Fragment() { return context.card(context.cardColumn().apply { val goodsLabel = item.goodsId.ifBlank { "未识别商品" } addView(context.label("#${item.taskId} · 第 ${item.attemptNumber} 次 · $goodsLabel", 17f, context.getColor(R.color.agent_text), true)) + // #279:图搜任务和普通采集混在同一列表里,加一个行内标记让采购员看出这条商品是机器图搜找到的, + // 不改变既有 `collection` 布尔分叉的界面结构。 + if (item.source == "image_search") { + addView(context.label("来源:图搜自动匹配", 13f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(2), 0, 0) }) + } addView(context.label("$status · ${formatTime(item.finishedAt ?: item.createdAt)}", 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) }) addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) }) if (CollectionResetPolicy.showsListAction(item.status)) { diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/ImageSearchAssetPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/ImageSearchAssetPolicyTest.kt new file mode 100644 index 0000000..960db31 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/ImageSearchAssetPolicyTest.kt @@ -0,0 +1,127 @@ +package cn.ilapage.goauto.agent.automation + +import cn.ilapage.goauto.agent.network.ImageSearchImage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImageSearchAssetPolicyTest { + + private fun jpegBytes(payloadSize: Int = 10): ByteArray { + val body = ByteArray(payloadSize) { 0x11 } + return byteArrayOf(0xff.toByte(), 0xd8.toByte()) + body + byteArrayOf(0xff.toByte(), 0xd9.toByte()) + } + + @Test + fun `valid returns true for matching jpeg with correct markers and hash`() { + val bytes = jpegBytes() + val reference = ImageSearchImage( + imageUrl = "https://example.com/a.jpg", + mediaType = "image/jpeg", + sizeBytes = bytes.size.toLong(), + sha256 = ImageSearchAssetPolicy.sha256(bytes), + ) + assertTrue(ImageSearchAssetPolicy.valid(bytes, reference)) + } + + @Test + fun `valid rejects jpeg missing markers`() { + val bytes = ByteArray(20) { 0x11 } // no ff d8 / ff d9 + val reference = ImageSearchImage( + imageUrl = "https://example.com/a.jpg", + mediaType = "image/jpeg", + sizeBytes = bytes.size.toLong(), + sha256 = ImageSearchAssetPolicy.sha256(bytes), + ) + assertFalse(ImageSearchAssetPolicy.valid(bytes, reference)) + } + + @Test + fun `valid rejects size mismatch`() { + val bytes = jpegBytes() + val reference = ImageSearchImage( + imageUrl = "https://example.com/a.jpg", + mediaType = "image/jpeg", + sizeBytes = bytes.size.toLong() + 1, + sha256 = ImageSearchAssetPolicy.sha256(bytes), + ) + assertFalse(ImageSearchAssetPolicy.valid(bytes, reference)) + } + + @Test + fun `valid rejects hash mismatch`() { + val bytes = jpegBytes() + val reference = ImageSearchImage( + imageUrl = "https://example.com/a.jpg", + mediaType = "image/jpeg", + sizeBytes = bytes.size.toLong(), + sha256 = "0".repeat(64), + ) + assertFalse(ImageSearchAssetPolicy.valid(bytes, reference)) + } + + @Test + fun `valid rejects unsupported media type`() { + val bytes = jpegBytes() + val reference = ImageSearchImage( + imageUrl = "https://example.com/a.gif", + mediaType = "image/gif", + sizeBytes = bytes.size.toLong(), + sha256 = ImageSearchAssetPolicy.sha256(bytes), + ) + assertFalse(ImageSearchAssetPolicy.valid(bytes, reference)) + } + + @Test + fun `valid rejects oversized payload`() { + val bytes = jpegBytes(ImageSearchAssetPolicy.MAX_BYTES) + val reference = ImageSearchImage( + imageUrl = "https://example.com/a.jpg", + mediaType = "image/jpeg", + sizeBytes = bytes.size.toLong(), + sha256 = ImageSearchAssetPolicy.sha256(bytes), + ) + assertFalse(ImageSearchAssetPolicy.valid(bytes, reference)) + } + + @Test + fun `hasJpegMarkers detects ff d8 ff d9 boundaries`() { + assertTrue(ImageSearchAssetPolicy.hasJpegMarkers(jpegBytes())) + assertFalse(ImageSearchAssetPolicy.hasJpegMarkers(byteArrayOf(1, 2, 3))) + } + + @Test + fun `targetScaledSize returns null when already above minimum edge`() { + assertNull(ImageSearchAssetPolicy.targetScaledSize(1200, 1200)) + assertNull(ImageSearchAssetPolicy.targetScaledSize(960, 960)) + } + + @Test + fun `targetScaledSize scales up small square image to minimum edge`() { + val (w, h) = ImageSearchAssetPolicy.targetScaledSize(480, 480)!! + assertEquals(960, w) + assertEquals(960, h) + } + + @Test + fun `targetScaledSize preserves aspect ratio for narrow image`() { + // width limits: scale = 960/100 = 9.6 -> height 200*9.6=1920 + val (w, h) = ImageSearchAssetPolicy.targetScaledSize(100, 200)!! + assertEquals(960, w) + assertEquals(1920, h) + } + + @Test + fun `targetScaledSize gives up when scaling would exceed max edge`() { + // width 10 -> scale 96 -> height 10*96=960 within max, but width*96=960... choose extreme + assertNull(ImageSearchAssetPolicy.targetScaledSize(1, 5000)) + } + + @Test + fun `targetScaledSize rejects non positive dimensions`() { + assertNull(ImageSearchAssetPolicy.targetScaledSize(0, 100)) + assertNull(ImageSearchAssetPolicy.targetScaledSize(100, 0)) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteriaTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteriaTest.kt new file mode 100644 index 0000000..3ceda88 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/automation/PinduoduoImageSearchCriteriaTest.kt @@ -0,0 +1,64 @@ +package cn.ilapage.goauto.agent.automation + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PinduoduoImageSearchCriteriaTest { + + @Test + fun `isImageSearchEntryPage requires all four signals`() { + val complete = listOf("我的相册", "最近搜索", "历史浏览", "点击拍照即可开始识别") + assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(complete)) + + val missingHint = listOf("我的相册", "最近搜索", "历史浏览") + assertFalse(PinduoduoImageSearchCriteria.isImageSearchEntryPage(missingHint)) + + val alternateHint = listOf("我的相册", "最近搜索", "历史浏览", "开启相机权限") + assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(alternateHint)) + + val thirdHintVariant = listOf("我的相册", "最近搜索", "历史浏览", "拍摄清晰照片即可进行自动识别") + assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(thirdHintVariant)) + } + + @Test + fun `isImageSearchResultsPage requires title and enough sort controls`() { + assertTrue(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 3)) + assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 2)) + assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("其他标题"), sortControlCount = 5)) + } + + @Test + fun `isRetryDialog requires hint text plus both buttons`() { + val texts = listOf("请对准商品或码,保持手机稳定", "取消", "再试一次") + assertTrue(PinduoduoImageSearchCriteria.isRetryDialog(texts)) + assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("请对准商品或码,保持手机稳定", "取消"))) + assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("取消", "再试一次"))) + } + + @Test + fun `firstRecentImageGridCell picks topmost then leftmost four column cell`() { + val screenWidth = 1080 + val cellWidth = screenWidth / 4 // 270 + val candidates = listOf( + PinduoduoImageSearchCriteria.GridCandidate(top = 200, left = cellWidth, width = cellWidth, height = cellWidth), + PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = cellWidth * 2, width = cellWidth, height = cellWidth), + PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = 0, width = cellWidth, height = cellWidth), + // not a 4-column cell (too narrow height ratio) + PinduoduoImageSearchCriteria.GridCandidate(top = 50, left = 0, width = cellWidth, height = 10), + ) + val result = PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth) + assertTrue(result != null) + assertTrue(result!!.top == 100 && result.left == 0) + } + + @Test + fun `firstRecentImageGridCell returns null when nothing matches grid shape`() { + val screenWidth = 1080 + val candidates = listOf( + PinduoduoImageSearchCriteria.GridCandidate(top = 0, left = 0, width = 1080, height = 100), + ) + assertNull(PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth)) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/service/TaskDispatchPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/service/TaskDispatchPolicyTest.kt new file mode 100644 index 0000000..60bd91e --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/service/TaskDispatchPolicyTest.kt @@ -0,0 +1,84 @@ +package cn.ilapage.goauto.agent.service + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TaskDispatchPolicyTest { + + // -- 回归:新增 hasImageSearchWaiting 参数前的四条既有路径必须逐字节不变 -- + + @Test + fun `spec probe pending waits for purchase match regardless of cooldown`() { + assertEquals( + TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH, + TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = false), + ) + assertEquals( + TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH, + TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = true), + ) + } + + @Test + fun `any other purchase status runs purchase`() { + assertEquals( + TaskDispatchDecision.RUN_PURCHASE, + TaskDispatchPolicy.decide("matched", collectionCooldownActive = false), + ) + assertEquals( + TaskDispatchDecision.RUN_PURCHASE, + TaskDispatchPolicy.decide("matched", collectionCooldownActive = true), + ) + } + + @Test + fun `no purchase status and cooldown active waits for cooldown`() { + assertEquals( + TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN, + TaskDispatchPolicy.decide(null, collectionCooldownActive = true), + ) + } + + @Test + fun `no purchase status and no cooldown checks collection`() { + assertEquals( + TaskDispatchDecision.CHECK_COLLECTION, + TaskDispatchPolicy.decide(null, collectionCooldownActive = false), + ) + } + + @Test + fun `default parameter preserves legacy three-argument call sites`() { + // 显式验证不传 hasImageSearchWaiting 时行为与旧签名完全一致。 + assertEquals( + TaskDispatchDecision.CHECK_COLLECTION, + TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false), + ) + } + + // -- 新增:采购 > 采集 > 图搜 -- + + @Test + fun `image search only routes when no purchase and no cooldown and explicitly flagged`() { + assertEquals( + TaskDispatchDecision.CHECK_IMAGE_SEARCH, + TaskDispatchPolicy.decide(null, collectionCooldownActive = false, hasImageSearchWaiting = true), + ) + } + + @Test + fun `purchase still takes priority over image search flag`() { + assertEquals( + TaskDispatchDecision.RUN_PURCHASE, + TaskDispatchPolicy.decide("matched", collectionCooldownActive = false, hasImageSearchWaiting = true), + ) + } + + @Test + fun `collection cooldown still takes priority over image search flag`() { + assertEquals( + TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN, + TaskDispatchPolicy.decide(null, collectionCooldownActive = true, hasImageSearchWaiting = true), + ) + } +}