feat(t211): return SKU-matched image search candidates

This commit is contained in:
QiuSW
2026-07-27 18:35:59 +08:00
parent 667a64f6c2
commit edfee2c35e
26 changed files with 1838 additions and 153 deletions
@@ -0,0 +1,149 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.workflow.SafetyStopReason
import com.roubao.autopilot.workflow.WorkflowRunner
import com.roubao.autopilot.workflow.WorkflowState
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoImageSearchAutomationTest {
@Test
fun `selects prepared image and verifies image results`() = runTest {
val driver = FakeImageDriver(PinduoduoPage.SEARCH_RESULTS)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertTrue(driver.imageSearchOpened)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `selection verification failure blocks instead of using another image`() =
runTest {
val driver = FakeImageDriver(
page = PinduoduoPage.SEARCH_RESULTS,
selectionAllowed = false
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.BLOCKED, report.state)
assertEquals(SafetyStopReason.UNKNOWN_PAGE, report.safetyStopReason)
assertFalse(driver.preparedImageSelected)
}
@Test
fun `recovers when camera result opens before prepared image selection`() =
runTest {
val driver = FakeImageDriver(
page = PinduoduoPage.SEARCH_RESULTS,
cameraResultRaceOnce = true
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.returnFromImageResultsCalls)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `payment marker stops before image entry`() = runTest {
val driver = FakeImageDriver(
page = PinduoduoPage.UNKNOWN,
safetyStopReason = SafetyStopReason.PAYMENT_BOUNDARY
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.BLOCKED, report.state)
assertFalse(driver.imageSearchOpened)
}
private class FakeImageDriver(
var page: PinduoduoPage,
private val selectionAllowed: Boolean = true,
private val safetyStopReason: SafetyStopReason? = null,
private val cameraResultRaceOnce: Boolean = false
) : PinduoduoImageSearchDriver {
var imageSearchOpened = false
var preparedImageSelected = false
var returnFromImageResultsCalls = 0
private var cameraResultRaceConsumed = false
override suspend fun openApp(): Boolean = true
override suspend fun snapshot(): PinduoduoUiSnapshot =
PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = safetyStopReason
)
override suspend fun openImageSearch(): Boolean {
imageSearchOpened = true
page = if (cameraResultRaceOnce && !cameraResultRaceConsumed) {
cameraResultRaceConsumed = true
PinduoduoPage.IMAGE_SEARCH_RESULTS
} else {
PinduoduoPage.IMAGE_SEARCH
}
return true
}
override suspend fun selectPreparedImage(): Boolean {
if (!selectionAllowed) {
return false
}
preparedImageSelected = true
page = PinduoduoPage.IMAGE_SEARCH_RESULTS
return true
}
override suspend fun returnFromCandidate(): Boolean {
page = PinduoduoPage.IMAGE_SEARCH_RESULTS
return true
}
override suspend fun returnFromImageResults(): Boolean {
returnFromImageResultsCalls += 1
page = PinduoduoPage.IMAGE_SEARCH
return true
}
}
}
@@ -4,6 +4,7 @@ import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.workflow.SafetyStopReason
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoPageClassifierTest {
@@ -102,6 +103,41 @@ class PinduoduoPageClassifierTest {
assertEquals(PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY, snapshot.page)
}
@Test
fun `image search camera page requires album and camera markers`() {
val snapshot = classify(
element(text = "我的相册"),
element(text = "最近搜索"),
element(text = "历史浏览"),
element(text = "点击拍照")
)
assertEquals(PinduoduoPage.IMAGE_SEARCH, snapshot.page)
}
@Test
fun `image search results require header and sort controls`() {
val snapshot = classify(
element(text = "搜图片同款"),
element(text = "综合"),
element(text = "销量"),
element(text = "价格")
)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, snapshot.page)
assertTrue(snapshot.page.isCandidateResultsPage())
}
@Test
fun `partial image page markers remain unknown`() {
val snapshot = classify(
element(text = "我的相册"),
element(text = "最近项目")
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
}
@Test
fun `login verification and risk markers remain distinct`() {
assertEquals(
@@ -0,0 +1,40 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.task.ProbeReferenceImage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoReferenceImagePolicyTest {
@Test
fun `accepts only the declared jpeg bytes and hash`() {
val bytes = byteArrayOf(
0xff.toByte(),
0xd8.toByte(),
0xff.toByte(),
0xd9.toByte()
)
val reference = reference(bytes)
assertTrue(PinduoduoReferenceImagePolicy.isValid(bytes, reference))
assertFalse(
PinduoduoReferenceImagePolicy.isValid(
bytes + 0,
reference
)
)
assertFalse(
PinduoduoReferenceImagePolicy.isValid(
bytes,
reference.copy(sha256 = "0".repeat(64))
)
)
}
private fun reference(bytes: ByteArray) = ProbeReferenceImage(
relativePath = "reference.jpg",
mediaType = "image/jpeg",
sizeBytes = bytes.size.toLong(),
sha256 = PinduoduoEvidenceHash.sha256(bytes)
)
}