feat(android): add Pinduoduo search probe

This commit is contained in:
QiuSW
2026-07-25 19:40:19 +08:00
parent 1c1c158149
commit f226d0f2ff
21 changed files with 1356 additions and 70 deletions
@@ -0,0 +1,135 @@
package com.roubao.autopilot.pinduoduo
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.Test
class PinduoduoPageClassifierTest {
@Test
fun `home page requires search entry and home marker`() {
val snapshot = classify(
element(contentDescription = "搜索"),
element(text = "首页")
)
assertEquals(PinduoduoPage.HOME, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `editable search page is recognized`() {
val snapshot = classify(
element(
contentDescription = "搜索",
className = "android.widget.EditText",
editable = true
),
element(text = "搜索", clickable = true)
)
assertEquals(PinduoduoPage.SEARCH_INPUT, snapshot.page)
}
@Test
fun `result controls identify search results`() {
val snapshot = classify(
element(
contentDescription = "搜索",
className = "android.widget.HorizontalScrollView"
),
element(text = SEARCH_PROBE_KEYWORD),
element(text = "综合"),
element(text = "销量"),
element(text = "价格"),
element(text = "筛选")
)
assertEquals(PinduoduoPage.SEARCH_RESULTS, snapshot.page)
}
@Test
fun `login verification and risk markers remain distinct`() {
assertEquals(
SafetyStopReason.LOGIN_REQUIRED,
classify(element(text = "请先登录")).safetyStopReason
)
assertEquals(
SafetyStopReason.VERIFICATION_REQUIRED,
classify(element(text = "请输入验证码")).safetyStopReason
)
assertEquals(
SafetyStopReason.RISK_CONTROL,
classify(element(text = "账号存在风险,请完成安全验证")).safetyStopReason
)
}
@Test
fun `payment boundary blocks even on otherwise unknown page`() {
val snapshot = classify(element(text = "确认订单"))
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertEquals(SafetyStopReason.PAYMENT_BOUNDARY, snapshot.safetyStopReason)
}
@Test
fun `hidden and disabled lookalikes do not classify a page`() {
val snapshot = classify(
element(contentDescription = "搜索", visibleToUser = false),
element(text = "首页", enabled = false)
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `results require the exact expected query`() {
val snapshot = classify(
element(
contentDescription = "搜索",
className = "android.widget.HorizontalScrollView"
),
element(text = "$SEARCH_PROBE_KEYWORD 推荐"),
element(text = "综合"),
element(text = "销量"),
element(text = "价格")
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
}
@Test
fun `other foreground package is unknown and has no inferred blocker`() {
val snapshot = PinduoduoPageClassifier.classify(
foregroundPackage = "com.roubao.autopilot",
elements = listOf(element(text = "请先登录"))
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
private fun classify(vararg elements: PinduoduoUiElement): PinduoduoUiSnapshot =
PinduoduoPageClassifier.classify(PINDUODUO_PACKAGE, elements.toList())
private fun element(
text: String? = null,
contentDescription: String? = null,
className: String = "android.widget.TextView",
editable: Boolean = false,
clickable: Boolean = false,
enabled: Boolean = true,
visibleToUser: Boolean = true
) = PinduoduoUiElement(
text = text,
contentDescription = contentDescription,
className = className,
resourceId = null,
clickable = clickable,
editable = editable,
enabled = enabled,
visibleToUser = visibleToUser
)
}
@@ -0,0 +1,131 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.workflow.AutomationResult
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.assertTrue
import org.junit.Test
class PinduoduoSearchAutomationTest {
@Test
fun `fixed keyword workflow reaches verified results`() = runTest {
val driver = FakeDriver(page = PinduoduoPage.HOME)
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(SEARCH_PROBE_KEYWORD, driver.enteredKeyword)
assertTrue(driver.searchSubmitted)
assertEquals(PinduoduoPage.SEARCH_RESULTS, driver.page)
}
@Test
fun `transient missing search entry uses bounded workflow retry`() = runTest {
val driver = FakeDriver(
page = PinduoduoPage.HOME,
failedOpenSearchAttempts = 1
)
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(2, driver.openSearchCalls)
assertEquals(2, report.attemptsByStep[PinduoduoSearchWorkflow.ENTER_QUERY])
}
@Test
fun `login blocker stops before any search action`() = runTest {
val driver = FakeDriver(
page = PinduoduoPage.UNKNOWN,
safetyStopReason = SafetyStopReason.LOGIN_REQUIRED
)
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.BLOCKED, report.state)
assertEquals(SafetyStopReason.LOGIN_REQUIRED, report.safetyStopReason)
assertEquals(0, driver.openSearchCalls)
}
@Test
fun `stable unknown Pinduoduo page blocks safely`() = runTest {
val driver = FakeDriver(page = PinduoduoPage.UNKNOWN)
val automation = PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 10,
unknownPageLimit = 3
)
val result = automation.execute(PinduoduoSearchWorkflow.steps().first())
assertEquals(
AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE),
result
)
}
private class FakeDriver(
var page: PinduoduoPage,
private val safetyStopReason: SafetyStopReason? = null,
private var failedOpenSearchAttempts: Int = 0
) : PinduoduoUiDriver {
var enteredKeyword: String? = null
var searchSubmitted = false
var openSearchCalls = 0
override suspend fun openApp(): Boolean = true
override suspend fun snapshot(): PinduoduoUiSnapshot =
PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = safetyStopReason
)
override suspend fun openSearch(): Boolean {
openSearchCalls += 1
if (failedOpenSearchAttempts > 0) {
failedOpenSearchAttempts -= 1
return false
}
page = PinduoduoPage.SEARCH_INPUT
return true
}
override suspend fun setSearchKeyword(keyword: String): Boolean {
enteredKeyword = keyword
return true
}
override suspend fun submitSearch(): Boolean {
searchSubmitted = true
page = PinduoduoPage.SEARCH_RESULTS
return true
}
}
}