feat(t217): verify authorized order dry runs
This commit is contained in:
+174
@@ -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轻奢小众夏季气质新款百搭漂亮上衣出片"
|
||||
}
|
||||
}
|
||||
+116
-2
@@ -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
|
||||
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -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
|
||||
)
|
||||
}
|
||||
+267
@@ -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)
|
||||
}
|
||||
}
|
||||
+83
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user