feat(android): collect bounded Pinduoduo candidates

This commit is contained in:
QiuSW
2026-07-25 20:18:05 +08:00
parent f226d0f2ff
commit c25d63c730
23 changed files with 1449 additions and 33 deletions
@@ -0,0 +1,260 @@
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.WorkflowFailureCode
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoCandidateAutomationTest {
@Test
fun `collects five distinct candidates with bounded scrolling`() = runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(
listOf(card("a"), card("b"), card("c")),
listOf(card("c"), card("d"), card("e"), card("f"))
)
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(AutomationResult.Success, result)
assertEquals(listOf("a", "b", "c", "d", "e"), driver.openedSignatures)
assertEquals(5, automation.evidence.value.size)
assertEquals(1, driver.scrollCalls)
assertEquals(5, driver.savedEvidence.size)
}
@Test
fun `configured candidate limit is a hard upper bound`() = runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(
listOf(card("a"), card("b"), card("c"), card("d"))
)
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
maxCandidates = 2,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(AutomationResult.Success, result)
assertEquals(2, driver.openedSignatures.size)
assertEquals(2, automation.evidence.value.size)
assertEquals(0, driver.scrollCalls)
}
@Test
fun `bounded old-page observations are allowed during transitions`() = runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
transitionDelaySnapshots = 2
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
maxCandidates = 1,
pagePollIntervalMillis = 1,
unknownPageLimit = 4
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(AutomationResult.Success, result)
assertEquals(1, automation.evidence.value.size)
}
@Test
fun `empty result scanning never exceeds scroll budget`() = runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(emptyList(), emptyList(), emptyList())
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
maxResultScrolls = 2,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(
AutomationResult.FatalFailure(WorkflowFailureCode.TARGET_NOT_READY),
result
)
assertEquals(2, driver.scrollCalls)
assertTrue(driver.openedSignatures.isEmpty())
}
@Test
fun `safety blocker prevents candidate actions`() = runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
safetyStopReason = SafetyStopReason.RISK_CONTROL
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(
AutomationResult.Blocked(SafetyStopReason.RISK_CONTROL),
result
)
assertTrue(driver.openedSignatures.isEmpty())
assertTrue(driver.savedEvidence.isEmpty())
}
@Test
fun `missing screenshot evidence fails without opening another candidate`() =
runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"), card("b"))),
failCapture = true
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(
AutomationResult.FatalFailure(
WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED
),
result
)
assertEquals(listOf("a"), driver.openedSignatures)
assertTrue(automation.evidence.value.isEmpty())
}
private fun card(signature: String) = PinduoduoCandidateCard(
signature = signature,
semanticTextCount = 3,
hasImage = true
)
private class FakeCandidateDriver(
private val cardPages: List<List<PinduoduoCandidateCard>>,
private val safetyStopReason: SafetyStopReason? = null,
private val failCapture: Boolean = false,
private val transitionDelaySnapshots: Int = 0
) : PinduoduoCandidateDriver {
private var cardPageIndex = 0
private var page = PinduoduoPage.SEARCH_RESULTS
private var pendingPage: PinduoduoPage? = null
private var delayedSnapshotsRemaining = 0
val openedSignatures = mutableListOf<String>()
val savedEvidence = mutableListOf<PinduoduoCandidateEvidence>()
var scrollCalls = 0
override suspend fun snapshot(): PinduoduoUiSnapshot {
if (pendingPage != null) {
if (delayedSnapshotsRemaining > 0) {
delayedSnapshotsRemaining -= 1
} else {
page = pendingPage ?: page
pendingPage = null
}
}
return PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = safetyStopReason
)
}
override suspend fun candidateCards(
limit: Int
): List<PinduoduoCandidateCard> =
cardPages.getOrElse(cardPageIndex) { emptyList() }.take(limit)
override suspend fun openCandidate(signature: String): Boolean {
openedSignatures += signature
transitionTo(PinduoduoPage.PRODUCT_DETAIL)
return true
}
override suspend fun captureCandidate(
ordinal: Int,
card: PinduoduoCandidateCard
): PinduoduoCandidateEvidence? {
if (failCapture) {
return null
}
return PinduoduoCandidateEvidence(
ordinal = ordinal,
cardSignature = card.signature,
cardSemanticTextCount = card.semanticTextCount,
detailSignature = "detail-${card.signature}",
detailSemanticTextCount = 10,
screenshotFileName = "candidate-%02d.png".format(ordinal),
screenshotSha256 = "hash-$ordinal",
screenshotByteCount = 100 + ordinal,
screenshotWidth = 1080,
screenshotHeight = 2400
).also(savedEvidence::add)
}
override suspend fun returnToResults(): Boolean {
transitionTo(PinduoduoPage.SEARCH_RESULTS)
return true
}
override suspend fun scrollResults(): Boolean {
scrollCalls += 1
if (cardPageIndex + 1 < cardPages.size) {
cardPageIndex += 1
}
return true
}
override fun resetEvidence() {
openedSignatures.clear()
savedEvidence.clear()
scrollCalls = 0
cardPageIndex = 0
page = PinduoduoPage.SEARCH_RESULTS
pendingPage = null
delayedSnapshotsRemaining = 0
}
private fun transitionTo(nextPage: PinduoduoPage) {
if (transitionDelaySnapshots == 0) {
page = nextPage
} else {
pendingPage = nextPage
delayedSnapshotsRemaining = transitionDelaySnapshots
}
}
}
}
@@ -100,6 +100,19 @@ class PinduoduoPageClassifierTest {
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
}
@Test
fun `detail page requires clickable back and multiple stable markers`() {
val snapshot = classify(
element(contentDescription = "返回", clickable = true),
element(contentDescription = "联系客服"),
element(contentDescription = "收藏商品"),
element(text = "店铺")
)
assertEquals(PinduoduoPage.PRODUCT_DETAIL, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `other foreground package is unknown and has no inferred blocker`() {
val snapshot = PinduoduoPageClassifier.classify(
@@ -89,6 +89,24 @@ class PinduoduoSearchAutomationTest {
)
}
@Test
fun `workflow safely recovers from a verified candidate detail`() = runTest {
val driver = FakeDriver(page = PinduoduoPage.PRODUCT_DETAIL)
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.returnFromCandidateCalls)
assertEquals(PinduoduoPage.SEARCH_RESULTS, driver.page)
}
private class FakeDriver(
var page: PinduoduoPage,
private val safetyStopReason: SafetyStopReason? = null,
@@ -97,6 +115,7 @@ class PinduoduoSearchAutomationTest {
var enteredKeyword: String? = null
var searchSubmitted = false
var openSearchCalls = 0
var returnFromCandidateCalls = 0
override suspend fun openApp(): Boolean = true
@@ -127,5 +146,11 @@ class PinduoduoSearchAutomationTest {
page = PinduoduoPage.SEARCH_RESULTS
return true
}
override suspend fun returnFromCandidate(): Boolean {
returnFromCandidateCalls += 1
page = PinduoduoPage.SEARCH_RESULTS
return true
}
}
}