feat(android): collect bounded Pinduoduo candidates
This commit is contained in:
+260
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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(
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user