feat(android): add Pinduoduo search probe
This commit is contained in:
+135
@@ -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
|
||||
)
|
||||
}
|
||||
+131
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ class WorkflowRunnerTest {
|
||||
assertEquals(WorkflowState.SUCCEEDED, report.state)
|
||||
assertEquals(listOf("open_app", "verify_page"), report.completedStepIds)
|
||||
assertEquals(mapOf("open_app" to 1, "verify_page" to 1), report.attemptsByStep)
|
||||
assertEquals(null, runner.currentStepId.value)
|
||||
assertEquals(
|
||||
listOf(
|
||||
WorkflowState.RUNNING,
|
||||
@@ -79,6 +80,7 @@ class WorkflowRunnerTest {
|
||||
assertEquals(WorkflowState.FAILED, report.state)
|
||||
assertEquals(WorkflowFailureCode.TIMEOUT, report.failureCode)
|
||||
assertEquals(2, report.attemptsByStep["wait_for_page"])
|
||||
assertEquals("wait_for_page", runner.currentStepId.value)
|
||||
assertEquals(200, currentTime)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user