feat(t217): verify authorized order dry runs

This commit is contained in:
QiuSW
2026-07-28 16:52:29 +08:00
parent 913107c28d
commit 1e87b6273a
45 changed files with 4358 additions and 128 deletions
@@ -0,0 +1,174 @@
package com.roubao.autopilot.pinduoduo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PinduoduoCheckoutOcrPolicyTest {
@Test
fun `verifies checkout evidence using semantic relative positions`() {
val result = PinduoduoCheckoutOcrPolicy.verify(
lines = checkoutLines(),
authorizedTitle = TITLE,
expectedColor = "GRAY",
expectedSize = "2XL",
expectedQuantity = 1
)
assertEquals(TITLE, result?.observedTitle)
assertEquals("灰色 2XL 建议125-135斤", result?.selectedSummary)
assertEquals(1, result?.quantity)
assertEquals(1_435L, result?.totalPriceCents)
}
@Test
fun `rejects an unlabeled total`() {
val lines = checkoutLines().map { line ->
if (line.text.startsWith("实付款")) {
line.copy(text = "¥14.35")
} else {
line
}
}
assertNull(
PinduoduoCheckoutOcrPolicy.verify(
lines,
TITLE,
"GRAY",
"2XL",
1
)
)
}
@Test
fun `accepts color and size split from their labels by OCR`() {
val split = checkoutLines().flatMap { line ->
when {
line.text.startsWith("颜色分类") -> listOf(
line.copy(text = "颜色分类"),
line.copy(text = "灰色", left = 500)
)
line.text.startsWith("尺码") -> listOf(
line.copy(text = "尺码"),
line.copy(text = "2XL 建议125-135斤", left = 500)
)
else -> listOf(line)
}
}
val result = PinduoduoCheckoutOcrPolicy.verify(
split,
TITLE,
"GRAY",
"2XL",
1
)
assertEquals("灰色 2XL 建议125-135斤", result?.selectedSummary)
}
@Test
fun `rejects quantity outside the product and discount region`() {
val lines = checkoutLines().map { line ->
if (line.text == "1") line.copy(top = 950, bottom = 980) else line
}
assertNull(
PinduoduoCheckoutOcrPolicy.verify(
lines,
TITLE,
"GRAY",
"2XL",
1
)
)
}
@Test
fun `accepts quantity merged with the increment control by OCR`() {
val lines = checkoutLines().map { line ->
if (line.text == "1") line.copy(text = "1 +") else line
}
val result = PinduoduoCheckoutOcrPolicy.verify(
lines,
TITLE,
"GRAY",
"2XL",
1
)
assertEquals(1, result?.quantity)
}
@Test
fun `long title permits bounded OCR character errors`() {
val observed = "前缀" + TITLE.replace("轻奢", "轻著") + "后缀"
assertEquals(
true,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
observed,
TITLE
)
)
}
@Test
fun `long title permits split OCR anchors`() {
val observed = "设计拼接其他文字恤短袖更多文字26轻" +
"其他小众夏季气质新款其他漂亮上衣"
assertEquals(
true,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
observed,
TITLE
)
)
}
@Test
fun `long title permits a rendered prefix truncated by checkout`() {
assertEquals(
true,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
TITLE.take(18),
TITLE
)
)
}
@Test
fun `different title is not an approximate match`() {
assertEquals(
false,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
"夏季纯棉儿童运动套装宽松休闲两件套",
TITLE
)
)
}
private fun checkoutLines() = listOf(
line("手动添加收货地址", 100),
line(TITLE.substring(0, 18), 200),
line(TITLE.substring(18), 240),
line("颜色分类: 灰色", 300),
line("尺码: 2XL 建议125-135斤", 340),
line("1", 430),
line("店铺优惠", 600),
line("平台和限时优惠", 700),
line("实付款: ¥14.35 免运费", 1000)
)
private fun line(text: String, top: Int) =
PinduoduoOcrLine(text, 100, top, 800, top + 40)
private companion object {
const val TITLE =
"设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片"
}
}
@@ -75,6 +75,92 @@ class PinduoduoImageSearchAutomationTest {
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `dismisses camera retry dialog and reopens the prepared image`() =
runTest {
val driver = FakeImageDriver(
page = PinduoduoPage.SEARCH_RESULTS,
retryDialogOnce = 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.dismissRetryDialogCalls)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `recovers when app opens directly into the camera retry dialog`() =
runTest {
val driver = FakeImageDriver(
page = PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.dismissRetryDialogCalls)
assertTrue(driver.preparedImageSelected)
}
@Test
fun `recovers from specification panel left by candidate collection`() =
runTest {
val driver = FakeImageDriver(PinduoduoPage.SPECIFICATION_PANEL)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.closeSpecificationsCalls)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `backs out of order confirmation before a retry search`() = runTest {
val driver = FakeImageDriver(
PinduoduoPage.ORDER_CONFIRMATION,
safetyStopReason = SafetyStopReason.PAYMENT_BOUNDARY
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.returnFromOrderConfirmationCalls)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `payment marker stops before image entry`() = runTest {
val driver = FakeImageDriver(
@@ -99,12 +185,18 @@ class PinduoduoImageSearchAutomationTest {
var page: PinduoduoPage,
private val selectionAllowed: Boolean = true,
private val safetyStopReason: SafetyStopReason? = null,
private val cameraResultRaceOnce: Boolean = false
private val cameraResultRaceOnce: Boolean = false,
private val retryDialogOnce: Boolean = false
) : PinduoduoImageSearchDriver {
var imageSearchOpened = false
var preparedImageSelected = false
var closeSpecificationsCalls = 0
var returnFromOrderConfirmationCalls = 0
var returnFromImageResultsCalls = 0
var dismissRetryDialogCalls = 0
private var currentSafetyStopReason = safetyStopReason
private var cameraResultRaceConsumed = false
private var retryDialogConsumed = false
override suspend fun openApp(): Boolean = true
@@ -112,7 +204,7 @@ class PinduoduoImageSearchAutomationTest {
PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = safetyStopReason
safetyStopReason = currentSafetyStopReason
)
override suspend fun openImageSearch(): Boolean {
@@ -120,12 +212,21 @@ class PinduoduoImageSearchAutomationTest {
page = if (cameraResultRaceOnce && !cameraResultRaceConsumed) {
cameraResultRaceConsumed = true
PinduoduoPage.IMAGE_SEARCH_RESULTS
} else if (retryDialogOnce && !retryDialogConsumed) {
retryDialogConsumed = true
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
} else {
PinduoduoPage.IMAGE_SEARCH
}
return true
}
override suspend fun dismissImageSearchRetryDialog(): Boolean {
dismissRetryDialogCalls += 1
page = PinduoduoPage.HOME
return true
}
override suspend fun selectPreparedImage(): Boolean {
if (!selectionAllowed) {
return false
@@ -135,6 +236,19 @@ class PinduoduoImageSearchAutomationTest {
return true
}
override suspend fun closeSpecifications(): Boolean {
closeSpecificationsCalls += 1
page = PinduoduoPage.PRODUCT_DETAIL
return true
}
override suspend fun returnFromOrderConfirmation(): Boolean {
returnFromOrderConfirmationCalls += 1
page = PinduoduoPage.SPECIFICATION_PANEL
currentSafetyStopReason = null
return true
}
override suspend fun returnFromCandidate(): Boolean {
page = PinduoduoPage.IMAGE_SEARCH_RESULTS
return true
@@ -32,4 +32,24 @@ class PinduoduoObservedTitlePolicyTest {
assertNull(title)
}
@Test
fun `stable identity ignores volatile price and sales text`() {
val first = PinduoduoStableProductIdentityPolicy.signature(
listOf(
"限时 ¥19.90",
"已拼2.1万件",
"法式小香风V领短袖钉珠女2026夏季新款"
)
)
val refreshed = PinduoduoStableProductIdentityPolicy.signature(
listOf(
"限时 ¥21.00",
"已拼2.2万件",
"法式小香风V领短袖钉珠女2026夏季新款"
)
)
assertEquals(first, refreshed)
}
}
@@ -0,0 +1,44 @@
package com.roubao.autopilot.pinduoduo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class PinduoduoOrderConfirmationParserTest {
@Test
fun `parses modern split sku quantity and payable text`() {
val evidence = PinduoduoOrderConfirmationParser.parse(
listOf(
element("手动添加收货地址", top = 100),
element(
"设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片",
top = 200
),
element("颜色分类: 灰色", top = 300),
element("尺码: 2XL 建议125-135斤", top = 400),
element("1, 当前数量为1件", top = 500),
element(
"实付款¥14.35已享7.2折,共优惠5.64元免运费",
top = 600
)
)
)
assertNotNull(evidence)
assertEquals("灰色 2XL 建议125-135斤", evidence!!.selectedSummary)
assertEquals(1, evidence.quantity)
assertEquals(1_435L, evidence.totalPriceCents)
}
private fun element(text: String, top: Int) = PinduoduoUiElement(
text = text,
contentDescription = null,
className = "android.widget.TextView",
resourceId = null,
clickable = false,
editable = false,
enabled = true,
visibleToUser = true,
boundsTop = top
)
}
@@ -0,0 +1,267 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoOrderDryRunAutomationTest {
@Test
fun uniquelyMatchedCandidateStopsOnVerifiedOrderConfirmation() = runBlocking {
val driver = FakeDryRunDriver(listOf(card(CARD_A)))
val result = automation(driver).run()
assertEquals(CARD_A, result.candidate.cardSignature)
assertEquals(2, result.confirmation.quantity)
assertEquals(4_000L, result.confirmation.totalPriceCents)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, driver.page)
assertEquals(2, driver.quantity)
assertEquals(1, driver.quantityActions)
assertTrue(driver.confirmedSpecifications)
}
@Test
fun multipleMatchingDetailsAreRejectedBeforeQuantityOrConfirmation() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A), card(CARD_B)),
details = mapOf(
CARD_A to detail(DETAIL),
CARD_B to detail(DETAIL)
)
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("多个商品"))
assertEquals(0, driver.quantityActions)
assertTrue(!driver.confirmedSpecifications)
}
@Test
fun disabledSkuIsNotAnAuthorizedMatch() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
colorEnabled = false
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("没有唯一匹配"))
assertTrue(!driver.confirmedSpecifications)
}
@Test
fun quantityClickMustChangeExactlyOneStep() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
mutateQuantity = false
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("状态未按一步变化"))
assertTrue(!driver.confirmedSpecifications)
}
@Test
fun titleMismatchIsNeverAcceptedByMatchingSignatureAlone() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
details = mapOf(CARD_A to detail(DETAIL, "另一件完全不同的商品标题"))
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("没有唯一匹配"))
}
@Test
fun transientUnknownSnapshotBeforeScanningIsRetried() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
transientUnknownAtSnapshot = 2
)
val result = automation(driver).run()
assertEquals(CARD_A, result.candidate.cardSignature)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, driver.page)
}
private fun automation(driver: FakeDryRunDriver) =
PinduoduoOrderDryRunAutomation(
driver = driver,
authorizedTitle = TITLE,
authorizedCardSignature = CARD_A,
authorizedDetailSignature = DETAIL,
authorizedPriceText = "¥20.00",
specificationTarget = PinduoduoSpecificationTarget(
color = "BLACK",
size = "M"
),
quantity = 2,
maxBudgetCents = null,
pollIntervalMillis = 1,
pagePollLimit = 2
)
private class FakeDryRunDriver(
private val cards: List<PinduoduoCandidateCard>,
private val details: Map<String, PinduoduoCandidateDetailEvidence> =
cards.associate { it.signature to detail(DETAIL) },
private val colorEnabled: Boolean = true,
private val mutateQuantity: Boolean = true,
private val transientUnknownAtSnapshot: Int? = null
) : PinduoduoOrderDryRunDriver {
var page = PinduoduoPage.IMAGE_SEARCH_RESULTS
var quantity = 1
var quantityActions = 0
var confirmedSpecifications = false
private var snapshotCount = 0
private var currentCard: String? = null
private var colorSelected = false
private var sizeSelected = false
override suspend fun snapshot(): PinduoduoUiSnapshot {
snapshotCount += 1
if (snapshotCount == transientUnknownAtSnapshot) {
return PinduoduoUiSnapshot(
foregroundPackage = "",
page = PinduoduoPage.UNKNOWN,
safetyStopReason = null
)
}
return PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = null
)
}
override suspend fun candidateCards(limit: Int) = cards.take(limit)
override suspend fun openCandidate(signature: String): Boolean {
if (cards.none { it.signature == signature }) return false
currentCard = signature
page = PinduoduoPage.PRODUCT_DETAIL
return true
}
override suspend fun readCandidateDetail() =
details[currentCard]
override suspend fun openSpecifications(): Boolean {
page = PinduoduoPage.SPECIFICATION_PANEL
return true
}
override suspend fun readSpecifications() =
PinduoduoSpecificationEvidence(
signature = SPECIFICATION,
semanticTextCount = 8,
groups = listOf(
PinduoduoSpecificationGroup(
PinduoduoSpecificationGroupKind.COLOR,
"颜色",
listOf(
PinduoduoSpecificationOption(
"黑色",
colorSelected,
colorEnabled
)
),
complete = true
),
PinduoduoSpecificationGroup(
PinduoduoSpecificationGroupKind.SIZE,
"尺码",
listOf(
PinduoduoSpecificationOption(
"M",
sizeSelected,
enabled = true
)
),
complete = true
)
),
selectedSummary = "已选择 黑色 M",
price = PinduoduoSpecificationPrice("¥20.00", 2_000L)
)
override suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean {
when (kind) {
PinduoduoSpecificationGroupKind.COLOR -> {
if (!colorEnabled || optionText != "黑色") return false
colorSelected = true
}
PinduoduoSpecificationGroupKind.SIZE -> {
if (optionText != "M") return false
sizeSelected = true
}
}
return true
}
override suspend fun scrollSpecifications() = false
override suspend fun closeSpecifications(): Boolean {
page = PinduoduoPage.PRODUCT_DETAIL
return true
}
override suspend fun returnToResults(): Boolean {
page = PinduoduoPage.IMAGE_SEARCH_RESULTS
return true
}
override suspend fun scrollResults() = false
override suspend fun scrollResultsBackward() = false
override suspend fun readQuantity() = quantity
override suspend fun changeQuantity(increase: Boolean): Boolean {
quantityActions += 1
if (mutateQuantity) {
quantity += if (increase) 1 else -1
}
return true
}
override suspend fun confirmSpecifications(): Boolean {
confirmedSpecifications = true
page = PinduoduoPage.ORDER_CONFIRMATION
return true
}
override suspend fun readOrderConfirmation() =
PinduoduoOrderConfirmationEvidence(
observedTitle = TITLE,
selectedSummary = "已选 黑色 M",
quantity = quantity,
totalPriceCents = 4_000L
)
override suspend fun captureOrderConfirmation() =
PinduoduoScreenshotCapture(byteArrayOf(1, 2, 3), 1080, 2400)
}
private companion object {
const val TITLE = "测试商品标准标题"
val CARD_A = "a".repeat(64)
val CARD_B = "b".repeat(64)
val DETAIL = "c".repeat(64)
val SPECIFICATION = "d".repeat(64)
fun card(signature: String) =
PinduoduoCandidateCard(signature, 4, true)
fun detail(signature: String, title: String = TITLE) =
PinduoduoCandidateDetailEvidence(signature, 8, title)
}
}
@@ -115,6 +115,20 @@ class PinduoduoPageClassifierTest {
assertEquals(PinduoduoPage.IMAGE_SEARCH, snapshot.page)
}
@Test
fun `camera retry dialog is a recoverable image search page`() {
val snapshot = classify(
element(text = "请对准商品或码,保持手机稳定"),
element(text = "取消", clickable = true),
element(text = "再试一次", clickable = true)
)
assertEquals(
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG,
snapshot.page
)
}
@Test
fun `image search results require header and sort controls`() {
val snapshot = classify(
@@ -256,6 +270,63 @@ class PinduoduoPageClassifierTest {
)
}
@Test
fun `modern order confirmation uses address payment and payable markers`() {
val snapshot = classify(
element(text = "手动添加收货地址"),
element(text = "微信支付"),
element(text = "先用后付"),
element(text = "找好友支付"),
element(text = "实付款¥14.35已享7.2折,共优惠5.64元免运费")
)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page)
assertEquals(
SafetyStopReason.PAYMENT_BOUNDARY,
snapshot.safetyStopReason
)
}
@Test
fun `collapsed checkout webview remains a blocked confirmation page`() {
val snapshot = classify(
element(resourceId = "order_checkout")
)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page)
assertEquals(
SafetyStopReason.PAYMENT_BOUNDARY,
snapshot.safetyStopReason
)
}
@Test
fun `collapsed checkout structure is available only to contextual driver`() {
val elements = listOf(
element(
contentDescription = "返回",
clickable = true,
boundsTop = 154
),
element(
className = "android.webkit.WebView",
boundsLeft = 0,
boundsTop = 258,
boundsRight = 1080,
boundsBottom = 2328
)
)
val global = PinduoduoPageClassifier.classify(
PINDUODUO_PACKAGE,
elements
)
assertTrue(PinduoduoCollapsedCheckoutPolicy.matches(elements))
assertEquals(PinduoduoPage.UNKNOWN, global.page)
assertNull(global.safetyStopReason)
}
@Test
fun `order list uses stable status tabs`() {
val snapshot = classify(
@@ -309,18 +380,27 @@ class PinduoduoPageClassifierTest {
text: String? = null,
contentDescription: String? = null,
className: String = "android.widget.TextView",
resourceId: String? = null,
editable: Boolean = false,
clickable: Boolean = false,
enabled: Boolean = true,
visibleToUser: Boolean = true
visibleToUser: Boolean = true,
boundsLeft: Int = 0,
boundsTop: Int = 0,
boundsRight: Int = 0,
boundsBottom: Int = 0
) = PinduoduoUiElement(
text = text,
contentDescription = contentDescription,
className = className,
resourceId = null,
resourceId = resourceId,
clickable = clickable,
editable = editable,
enabled = enabled,
visibleToUser = visibleToUser
visibleToUser = visibleToUser,
boundsLeft = boundsLeft,
boundsTop = boundsTop,
boundsRight = boundsRight,
boundsBottom = boundsBottom
)
}