feat(t213): verify selected SKU combination price

This commit is contained in:
QiuSW
2026-07-27 23:57:02 +08:00
parent 5c08d8d7f3
commit 73b00b4063
18 changed files with 1301 additions and 140 deletions
@@ -157,7 +157,7 @@ class PinduoduoCandidateAutomationTest {
}
@Test
fun `missing safe specification entry blocks without purchase fallback`() =
fun `unsupported specification entry skips candidate safely`() =
runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
@@ -174,13 +174,175 @@ class PinduoduoCandidateAutomationTest {
)
assertEquals(
AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE),
AutomationResult.FatalFailure(
WorkflowFailureCode.TARGET_NOT_READY
),
result
)
assertEquals(1, driver.openSpecificationCalls)
assertEquals(1, driver.returnToResultsCalls)
assertTrue(driver.savedEvidence.isEmpty())
}
@Test
fun `selects target color then size and captures verified price`() =
runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a")))
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
specificationTarget = PinduoduoSpecificationTarget(
color = "BLACK",
size = "L"
),
maxCandidates = 1,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(AutomationResult.Success, result)
assertEquals(
listOf(
PinduoduoSpecificationGroupKind.COLOR to "黑色",
PinduoduoSpecificationGroupKind.SIZE to "L"
),
driver.selectedOptions
)
val specification = driver.savedEvidence.single().specification
assertEquals("已选择:黑色 L", specification.selectedSummary)
assertEquals(1090L, specification.price?.cents)
}
@Test
fun `direct order confirmation is exited without candidate save`() =
runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
openOrderConfirmation = true
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
maxCandidates = 1,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(
AutomationResult.FatalFailure(
WorkflowFailureCode.TARGET_NOT_READY
),
result
)
assertEquals(1, driver.returnFromOrderConfirmationCalls)
assertTrue(driver.savedEvidence.isEmpty())
}
@Test
fun `bounded specification scroll exposes target size`() = runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
sizeVisibleAfterSpecificationScroll = true
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
specificationTarget = PinduoduoSpecificationTarget(
color = "BLACK",
size = "L"
),
maxCandidates = 1,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(AutomationResult.Success, result)
assertEquals(1, driver.specificationScrollCalls)
assertEquals(
PinduoduoSpecificationGroupKind.SIZE to "L",
driver.selectedOptions.last()
)
}
@Test
fun `unchanged selection state is captured but not continued`() = runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
ignoreSpecificationSelection = true
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
specificationTarget = PinduoduoSpecificationTarget(
color = "BLACK",
size = "L"
),
maxCandidates = 1,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(AutomationResult.Success, result)
assertEquals(
listOf(PinduoduoSpecificationGroupKind.COLOR to "黑色"),
driver.selectedOptions
)
assertTrue(
driver.savedEvidence.single().specification.groups
.single {
it.kind == PinduoduoSpecificationGroupKind.COLOR
}
.options
.none { it.selected }
)
}
@Test
fun `missing target never exceeds specification scroll budget`() =
runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
hideSizeAlways = true
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
specificationTarget = PinduoduoSpecificationTarget(
color = "BLACK",
size = "L"
),
maxCandidates = 1,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(AutomationResult.Success, result)
assertEquals(2, driver.specificationScrollCalls)
assertEquals(
setOf(PinduoduoSpecificationGroupKind.COLOR),
driver.savedEvidence.single().specification.groups
.map { it.kind }
.toSet()
)
}
private fun card(signature: String) = PinduoduoCandidateCard(
signature = signature,
semanticTextCount = 3,
@@ -192,6 +354,10 @@ class PinduoduoCandidateAutomationTest {
private val safetyStopReason: SafetyStopReason? = null,
private val failCapture: Boolean = false,
private val failOpenSpecifications: Boolean = false,
private val openOrderConfirmation: Boolean = false,
private val sizeVisibleAfterSpecificationScroll: Boolean = false,
private val hideSizeAlways: Boolean = false,
private val ignoreSpecificationSelection: Boolean = false,
private val transitionDelaySnapshots: Int = 0
) : PinduoduoCandidateDriver {
private var cardPageIndex = 0
@@ -203,6 +369,13 @@ class PinduoduoCandidateAutomationTest {
val savedEvidence = mutableListOf<PinduoduoCandidateEvidence>()
var scrollCalls = 0
var openSpecificationCalls = 0
var returnToResultsCalls = 0
var returnFromOrderConfirmationCalls = 0
var specificationScrollCalls = 0
val selectedOptions =
mutableListOf<Pair<PinduoduoSpecificationGroupKind, String>>()
private val effectiveSelections =
mutableListOf<Pair<PinduoduoSpecificationGroupKind, String>>()
override suspend fun snapshot(): PinduoduoUiSnapshot {
if (pendingPage != null) {
@@ -217,6 +390,9 @@ class PinduoduoCandidateAutomationTest {
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = safetyStopReason
?: SafetyStopReason.PAYMENT_BOUNDARY.takeIf {
page == PinduoduoPage.ORDER_CONFIRMATION
}
)
}
@@ -250,14 +426,41 @@ class PinduoduoCandidateAutomationTest {
if (failOpenSpecifications) {
return false
}
transitionTo(PinduoduoPage.SPECIFICATION_PANEL)
transitionTo(
if (openOrderConfirmation) {
PinduoduoPage.ORDER_CONFIRMATION
} else {
PinduoduoPage.SPECIFICATION_PANEL
}
)
return true
}
override suspend fun captureSpecifications():
override suspend fun readSpecifications():
PinduoduoSpecificationEvidence = specification()
override suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean {
selectedOptions += kind to optionText
if (!ignoreSpecificationSelection) {
effectiveSelections += kind to optionText
}
return true
}
override suspend fun scrollSpecifications(): Boolean {
specificationScrollCalls += 1
return true
}
override suspend fun captureSpecifications(
evidence: PinduoduoSpecificationEvidence
):
PinduoduoSpecificationCapture =
PinduoduoSpecificationCapture(
evidence = specification(),
evidence = evidence,
screenshot = PinduoduoScreenshotCapture(
pngBytes = byteArrayOf(2),
width = 1080,
@@ -270,6 +473,12 @@ class PinduoduoCandidateAutomationTest {
return true
}
override suspend fun returnFromOrderConfirmation(): Boolean {
returnFromOrderConfirmationCalls += 1
transitionTo(PinduoduoPage.PRODUCT_DETAIL)
return true
}
override suspend fun saveCandidate(
ordinal: Int,
card: PinduoduoCandidateCard,
@@ -289,6 +498,7 @@ class PinduoduoCandidateAutomationTest {
}
override suspend fun returnToResults(): Boolean {
returnToResultsCalls += 1
transitionTo(PinduoduoPage.SEARCH_RESULTS)
return true
}
@@ -306,6 +516,11 @@ class PinduoduoCandidateAutomationTest {
savedEvidence.clear()
scrollCalls = 0
openSpecificationCalls = 0
returnToResultsCalls = 0
returnFromOrderConfirmationCalls = 0
specificationScrollCalls = 0
selectedOptions.clear()
effectiveSelections.clear()
cardPageIndex = 0
page = PinduoduoPage.SEARCH_RESULTS
pendingPage = null
@@ -335,24 +550,57 @@ class PinduoduoCandidateAutomationTest {
private fun specification() = PinduoduoSpecificationEvidence(
signature = "specification",
semanticTextCount = 6,
groups = listOf(
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.COLOR,
title = "颜色分类",
options = listOf(
PinduoduoSpecificationOption("黑色", false, true)
),
complete = true
),
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.SIZE,
title = "尺码",
options = listOf(
PinduoduoSpecificationOption("L", false, true)
),
complete = true
groups = buildList {
add(
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.COLOR,
title = "颜色分类",
options = listOf(
PinduoduoSpecificationOption(
"黑色",
effectiveSelections.any {
it.first ==
PinduoduoSpecificationGroupKind.COLOR
},
true
)
),
complete = true
)
)
)
if (
!hideSizeAlways &&
(
!sizeVisibleAfterSpecificationScroll ||
specificationScrollCalls > 0
)
) {
add(
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.SIZE,
title = "尺码",
options = listOf(
PinduoduoSpecificationOption(
"L",
selected = effectiveSelections.any {
it.first ==
PinduoduoSpecificationGroupKind.SIZE
},
enabled = true
)
),
complete = true
)
)
}
},
selectedSummary = effectiveSelections
.takeIf { it.isNotEmpty() }
?.joinToString(
prefix = "已选择:",
separator = " "
) { it.second },
price = PinduoduoSpecificationPrice("¥10.9", 1090)
)
}
}
@@ -156,7 +156,7 @@ class PinduoduoPageClassifierTest {
@Test
fun `payment boundary blocks even on otherwise unknown page`() {
val snapshot = classify(element(text = "确认订单"))
val snapshot = classify(element(text = "立即支付"))
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertEquals(SafetyStopReason.PAYMENT_BOUNDARY, snapshot.safetyStopReason)
@@ -215,6 +215,48 @@ class PinduoduoPageClassifierTest {
assertNull(snapshot.safetyStopReason)
}
@Test
fun `stable specification controls survive scrolled option groups`() {
val snapshot = classify(
element(text = "确认款式"),
element(text = "已选择:白色 2XL"),
element(contentDescription = "关闭", clickable = true),
element(text = "确定", clickable = true),
element(text = "尺码"),
element(text = "2XL", clickable = true)
)
assertEquals(PinduoduoPage.SPECIFICATION_PANEL, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `order confirmation is classified and remains payment blocked`() {
val snapshot = classify(
element(text = "确认订单"),
element(text = "提交订单", clickable = true)
)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page)
assertEquals(
SafetyStopReason.PAYMENT_BOUNDARY,
snapshot.safetyStopReason
)
}
@Test
fun `order list uses stable status tabs`() {
val snapshot = classify(
element(text = "我的订单"),
element(text = "待付款"),
element(text = "待发货"),
element(text = "待收货")
)
assertEquals(PinduoduoPage.ORDER_LIST, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `cart and order pages are never classified as product detail`() {
val cart = classify(
@@ -36,7 +36,7 @@ class PinduoduoSpecificationParserTest {
listOf(
element("颜色分类", top = 100),
element("黑色", top = 140, clickable = true),
element("", top = 150, scrollable = true),
element("", top = 50, bottom = 400, scrollable = true),
element("尺码", top = 240),
element("L", top = 280, clickable = true)
)
@@ -44,7 +44,7 @@ class PinduoduoSpecificationParserTest {
requireNotNull(evidence)
assertFalse(evidence.groups.first().complete)
assertTrue(evidence.groups.last().complete)
assertFalse(evidence.groups.last().complete)
}
@Test
@@ -75,9 +75,169 @@ class PinduoduoSpecificationParserTest {
)
}
@Test
fun `reads selected combination and strict cny price`() {
val evidence = PinduoduoSpecificationParser.parse(
listOf(
element("确认款式", top = 10, clickable = true),
element("¥ 10.9", top = 20),
element("2件9.5折", top = 30),
element("已选择: 白色 2XL 建议145-160斤", top = 40),
element("颜色分类", top = 100),
element("白色", top = 140, clickable = true, selected = true),
element("尺码", top = 240),
element(
"2XL 建议145-160斤",
top = 280,
clickable = true,
selected = true
),
element("确定", top = 500, clickable = true)
)
)
requireNotNull(evidence)
assertEquals(
"已选择: 白色 2XL 建议145-160斤",
evidence.selectedSummary
)
assertEquals(1090L, evidence.price?.cents)
assertEquals("¥ 10.9", evidence.price?.rawText)
assertFalse(evidence.priceAmbiguous)
}
@Test
fun `multiple explicit prices are ambiguous`() {
val evidence = PinduoduoSpecificationParser.parse(
listOf(
element("¥10.9", top = 20),
element("¥12", top = 30),
element("颜色分类", top = 100),
element("黑色", top = 140, clickable = true)
)
)
requireNotNull(evidence)
assertNull(evidence.price)
assertTrue(evidence.priceAmbiguous)
}
@Test
fun `range and conditional prices do not become combination price`() {
val evidence = PinduoduoSpecificationParser.parse(
listOf(
element("¥10.9-¥12.9", top = 20),
element("券后¥9.9", top = 30),
element("2件9.5折", top = 40),
element("颜色分类", top = 100),
element("黑色", top = 140, clickable = true)
)
)
requireNotNull(evidence)
assertNull(evidence.price)
assertFalse(evidence.priceAmbiguous)
}
@Test
fun `merges partial observations after bounded scroll`() {
val color = requireNotNull(
PinduoduoSpecificationParser.parse(
listOf(
element("¥10.9", top = 20),
element("已选择:白色", top = 40),
element("颜色分类", top = 100),
element(
"白色",
top = 140,
clickable = true,
selected = true
)
)
)
)
val size = requireNotNull(
PinduoduoSpecificationParser.parse(
listOf(
element("¥10.9", top = 20),
element("已选择:白色 2XL", top = 40),
element("尺码", top = 100),
element(
"2XL 建议145-160斤",
top = 140,
clickable = true,
selected = true
)
)
)
)
val merged = requireNotNull(
PinduoduoSpecificationParser.merge(listOf(color, size))
)
assertEquals(
PinduoduoSpecificationGroupKind.entries.toSet(),
merged.groups.map { it.kind }.toSet()
)
assertEquals("已选择:白色 2XL", merged.selectedSummary)
assertEquals(1090L, merged.price?.cents)
}
@Test
fun `selection policy rejects disabled and accepts selected alias`() {
val evidence = requireNotNull(
PinduoduoSpecificationParser.parse(
listOf(
element("已选择:黑色 XXL", top = 40),
element("颜色分类", top = 100),
element(
"经典黑色",
top = 140,
clickable = true,
selected = true
),
element("尺码", top = 240),
element(
"XXL",
top = 280,
clickable = true,
selected = true
),
element(
"3XL",
top = 320,
clickable = true,
enabled = false
)
)
)
)
assertEquals(
PinduoduoSpecificationOptionResolution.Ready(
optionText = "XXL",
alreadySelected = true
),
PinduoduoSpecificationSelectionPolicy.resolve(
evidence,
PinduoduoSpecificationGroupKind.SIZE,
"2XL"
)
)
assertEquals(
PinduoduoSpecificationOptionResolution.Disabled("3XL"),
PinduoduoSpecificationSelectionPolicy.resolve(
evidence,
PinduoduoSpecificationGroupKind.SIZE,
"3XL"
)
)
}
private fun element(
text: String,
top: Int,
bottom: Int = top + 1,
clickable: Boolean = false,
enabled: Boolean = true,
selected: Boolean = false,
@@ -93,6 +253,7 @@ class PinduoduoSpecificationParserTest {
visibleToUser = true,
selected = selected,
scrollable = scrollable,
boundsTop = top
boundsTop = top,
boundsBottom = bottom
)
}
@@ -4,6 +4,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroup
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationOption
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationPrice
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -54,6 +55,39 @@ class CandidateSpecificationHardConstraintPolicyTest {
)
}
@Test
fun `visible but unselected target is unknown`() {
assertEquals(
HardConstraintMatchStatus.UNKNOWN,
evaluate(
colorOptions = listOf(
option("黑色", selected = false)
),
sizeOptions = listOf(option("2XL"))
).first().status
)
}
@Test
fun `missing combination price makes selected targets unknown`() {
val evidence = specification(
colorOptions = listOf(option("黑色")),
sizeOptions = listOf(option("2XL"))
).copy(price = null)
val results = CandidateSpecificationHardConstraintPolicy.evaluate(
constraints = constraints(),
evidence = evidence,
evidenceSha256 = "b".repeat(64)
)
assertTrue(
results.all {
it.status == HardConstraintMatchStatus.UNKNOWN
}
)
}
@Test
fun `truncated group and duplicated matching option are unknown`() {
assertEquals(
@@ -134,15 +168,18 @@ class CandidateSpecificationHardConstraintPolicyTest {
sizeOptions,
true
)
)
),
selectedSummary = "已选择:经典黑色 XXL",
price = PinduoduoSpecificationPrice("¥10.9", 1090)
)
private fun option(
text: String,
enabled: Boolean = true
enabled: Boolean = true,
selected: Boolean = true
) = PinduoduoSpecificationOption(
text = text,
selected = false,
selected = selected,
enabled = enabled
)
}