feat(android): 图搜任务资产准备、页面判据与调度垫底 #277
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
+124
-7
@@ -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<Int, Int>? {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -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<String>): 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<String>, sortControlCount: Int): Boolean =
|
||||
texts.any { it.contains("搜图片同款") } && sortControlCount >= 3
|
||||
|
||||
/** "再试一次"弹窗判据。 */
|
||||
fun isRetryDialog(texts: Collection<String>): 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<GridCandidate>, 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()
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+127
@@ -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))
|
||||
}
|
||||
}
|
||||
+64
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user