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:
QiuSW
2026-09-15 10:39:41 +08:00
co-authored by Claude Opus 5
parent c1eb23e9b1
commit 51265681c2
8 changed files with 517 additions and 10 deletions
@@ -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
}
}
@@ -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()
}
}
@@ -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)) {
@@ -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))
}
}
@@ -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),
)
}
}