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
@@ -67,6 +67,7 @@ import com.roubao.autopilot.vlm.CandidateHumanReviewPolicy
import com.roubao.autopilot.vlm.CandidateTopFivePolicy
import com.roubao.autopilot.vlm.CandidateSpecificationHardConstraintPolicy
import com.roubao.autopilot.vlm.SkuHardConstraintExtractor
import com.roubao.autopilot.vlm.SkuConstraintKind
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -88,6 +89,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchAutomation
import com.roubao.autopilot.pinduoduo.PinduoduoImageProbeAutomation
import com.roubao.autopilot.pinduoduo.PinduoduoImageCandidateWorkflow
import com.roubao.autopilot.pinduoduo.PinduoduoReferenceImagePolicy
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTarget
import com.roubao.autopilot.pinduoduo.PDD_IMAGE_SEARCH_AUDIT_QUERY
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
@@ -627,8 +629,22 @@ class MainActivity : ComponentActivity() {
} else {
searchKeyword
}
val specificationTarget = (
boundRequirement?.sku ?: procurementTask?.sku
)?.let(SkuHardConstraintExtractor::extract)
?.takeIf { it.readyForAutomaticMatching }
?.let { constraints ->
val values = constraints.constraints.associate {
it.kind to it.expected
}
PinduoduoSpecificationTarget(
color = requireNotNull(values[SkuConstraintKind.COLOR]),
size = requireNotNull(values[SkuConstraintKind.SIZE])
)
}
val candidateAutomation = PinduoduoCandidateAutomation(
AndroidPinduoduoCandidateDriver(this)
driver = AndroidPinduoduoCandidateDriver(this),
specificationTarget = specificationTarget
)
candidateAutomation.reset()
searchProbeReport.value = null
@@ -1002,15 +1018,27 @@ class MainActivity : ComponentActivity() {
}
val drafts = ranked.map { rankedCandidate ->
val assessment = rankedCandidate.assessment
val candidateEvidence = requireNotNull(
evidenceByOrdinal[
rankedCandidate.sourceOrdinal
]
)
ExecutionCandidateDraft(
ordinal = rankedCandidate.rankedOrdinal,
title =
"拼多多图片候选 " +
rankedCandidate.sourceOrdinal,
skuText = assessment.hardConstraintResults
.joinToString(" / ") {
"${it.kind.name}:${it.expected}"
},
skuText =
candidateEvidence.specification
.selectedSummary
?: assessment
.hardConstraintResults
.joinToString(" / ") {
"${it.kind.name}:" +
it.expected
},
price = candidateEvidence.specification
.price?.rawText.orEmpty(),
evidenceLocalIDs = emptyList(),
evaluation = ExecutionCandidateEvaluation(
decision = assessment.decision.name,
@@ -1184,6 +1212,10 @@ class MainActivity : ComponentActivity() {
ExecutionCandidateDraft(
ordinal = candidate.ordinal,
title = "$taskTitle 候选 ${candidate.ordinal}",
skuText = candidate.specification
.selectedSummary.orEmpty(),
price = candidate.specification
.price?.rawText.orEmpty(),
evidenceLocalIDs = emptyList()
)
}
@@ -5,6 +5,7 @@ import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
import com.roubao.autopilot.readiness.DeviceObservationStore
import kotlinx.coroutines.Dispatchers
@@ -94,6 +95,18 @@ object BuyerAccessibilityBridge {
service?.readPinduoduoSpecificationEvidence()
}
suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean = withContext(Dispatchers.Main.immediate) {
service?.selectPinduoduoSpecificationOption(kind, optionText) == true
}
suspend fun scrollSpecifications(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.scrollPinduoduoSpecifications() == true
}
suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? =
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
withContext(Dispatchers.Main.immediate) {
@@ -108,6 +121,11 @@ object BuyerAccessibilityBridge {
service?.closePinduoduoSpecifications() == true
}
suspend fun returnFromOrderConfirmation(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.returnFromPinduoduoOrderConfirmation() == true
}
suspend fun returnToResults(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.returnFromPinduoduoCandidate() == true
@@ -25,6 +25,7 @@ import com.roubao.autopilot.pinduoduo.isCandidateResultsPage
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationParser
import java.io.ByteArrayOutputStream
import java.util.ArrayDeque
@@ -328,14 +329,25 @@ class BuyerAccessibilityService : AccessibilityService() {
if (!isVerifiedProductDetail(root)) {
return@withPinduoduoRoot false
}
val entries = collectNodes(root).filter { node ->
val nodes = collectNodes(root)
val directTargets = uniqueClickableTargets(nodes.filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
PinduoduoSpecificationParser.isSafeEntryText(
semanticText(node)
)
}
entries.singleOrNull()?.let(::clickNodeOrAncestor) == true
})
val target = directTargets.singleOrNull()
?: uniqueClickableTargets(
nodes.filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
normalizeActionText(semanticText(node))
.endsWith(SPECIFICATION_PURCHASE_ENTRY)
}
).singleOrNull()
?: return@withPinduoduoRoot false
target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
} ?: false
internal fun readPinduoduoSpecificationEvidence():
@@ -353,6 +365,67 @@ class BuyerAccessibilityService : AccessibilityService() {
)
}
internal fun selectPinduoduoSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.safetyStopReason != null ||
snapshot.page != PinduoduoPage.SPECIFICATION_PANEL
) {
return@withPinduoduoRoot false
}
val evidence = PinduoduoSpecificationParser.parse(
collectNodes(root).map(::uiElement)
) ?: return@withPinduoduoRoot false
val group = evidence.groups
.filter { it.kind == kind }
.singleOrNull()
?: return@withPinduoduoRoot false
val option = group.options.filter { candidate ->
normalizeActionText(candidate.text) ==
normalizeActionText(optionText)
}.singleOrNull()
?.takeIf { it.enabled && !it.selected }
?: return@withPinduoduoRoot false
val normalizedOption = normalizeActionText(option.text)
val targets = uniqueClickableTargets(
collectNodes(root).filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
normalizeActionText(semanticText(node)) ==
normalizedOption
}
)
targets.singleOrNull()
?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
} ?: false
internal fun scrollPinduoduoSpecifications(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.safetyStopReason != null ||
snapshot.page != PinduoduoPage.SPECIFICATION_PANEL
) {
return@withPinduoduoRoot false
}
collectNodes(root)
.filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
node.isScrollable &&
node.className?.toString()
?.endsWith("ScrollView") == true
}
.singleOrNull()
?.performAction(
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
) == true
} ?: false
internal fun closePinduoduoSpecifications(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
@@ -365,6 +438,17 @@ class BuyerAccessibilityService : AccessibilityService() {
performGlobalAction(GLOBAL_ACTION_BACK)
} ?: false
internal fun returnFromPinduoduoOrderConfirmation(): Boolean =
withPinduoduoRoot { root ->
if (
classifyPinduoduoRoot(root).page !=
PinduoduoPage.ORDER_CONFIRMATION
) {
return@withPinduoduoRoot false
}
performGlobalAction(GLOBAL_ACTION_BACK)
} ?: false
internal fun returnFromPinduoduoCandidate(): Boolean =
withPinduoduoRoot { root ->
if (!isVerifiedProductDetail(root)) {
@@ -531,7 +615,9 @@ class BuyerAccessibilityService : AccessibilityService() {
selected = node.isSelected,
scrollable = node.isScrollable,
boundsLeft = bounds.left,
boundsTop = bounds.top
boundsTop = bounds.top,
boundsRight = bounds.right,
boundsBottom = bounds.bottom
)
}
@@ -802,6 +888,37 @@ class BuyerAccessibilityService : AccessibilityService() {
return false
}
private fun uniqueClickableTargets(
nodes: Collection<AccessibilityNodeInfo>
): List<AccessibilityNodeInfo> =
nodes.mapNotNull(::clickableNodeOrAncestor)
.distinctBy { node ->
val bounds = Rect().also(node::getBoundsInScreen)
listOf(
bounds.left,
bounds.top,
bounds.right,
bounds.bottom
)
}
private fun clickableNodeOrAncestor(
node: AccessibilityNodeInfo
): AccessibilityNodeInfo? {
var candidate: AccessibilityNodeInfo? = node
repeat(MAX_CLICK_ANCESTORS) {
val current = candidate ?: return null
if (current.isClickable && current.isEnabled) {
return current
}
candidate = current.parent
}
return null
}
private fun normalizeActionText(value: String): String =
value.trim().replace(Regex("\\s+"), "")
private data class CandidateNode(
val node: AccessibilityNodeInfo,
val card: PinduoduoCandidateCard
@@ -835,6 +952,7 @@ class BuyerAccessibilityService : AccessibilityService() {
private const val RECENT_PROJECTS_TEXT = "最近项目"
private const val IMAGE_GRID_COLUMNS = 4
private const val IMAGE_GRID_WIDTH_TOLERANCE = 24
private const val SPECIFICATION_PURCHASE_ENTRY = "免拼购买"
private val DECIMAL_PRICE_PATTERN =
Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$")
}
@@ -36,9 +36,21 @@ class AndroidPinduoduoCandidateDriver(
override suspend fun openSpecifications(): Boolean =
BuyerAccessibilityBridge.openSpecifications()
override suspend fun captureSpecifications(): PinduoduoSpecificationCapture? {
val evidence = BuyerAccessibilityBridge.specificationEvidence()
?: return null
override suspend fun readSpecifications(): PinduoduoSpecificationEvidence? =
BuyerAccessibilityBridge.specificationEvidence()
override suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean =
BuyerAccessibilityBridge.selectSpecificationOption(kind, optionText)
override suspend fun scrollSpecifications(): Boolean =
BuyerAccessibilityBridge.scrollSpecifications()
override suspend fun captureSpecifications(
evidence: PinduoduoSpecificationEvidence
): PinduoduoSpecificationCapture? {
val screenshot =
BuyerAccessibilityBridge.captureSpecificationScreenshot()
?: return null
@@ -48,6 +60,9 @@ class AndroidPinduoduoCandidateDriver(
override suspend fun closeSpecifications(): Boolean =
BuyerAccessibilityBridge.closeSpecifications()
override suspend fun returnFromOrderConfirmation(): Boolean =
BuyerAccessibilityBridge.returnFromOrderConfirmation()
override suspend fun saveCandidate(
ordinal: Int,
card: PinduoduoCandidateCard,
@@ -170,6 +185,22 @@ private class CandidateEvidenceStore(context: Context) {
"specification_semantic_text_count",
evidence.specification.semanticTextCount
)
.put(
"specification_selected_summary",
evidence.specification.selectedSummary
)
.put(
"specification_price_ambiguous",
evidence.specification.priceAmbiguous
)
.put(
"specification_price_raw",
evidence.specification.price?.rawText
)
.put(
"specification_price_cents",
evidence.specification.price?.cents
)
.put(
"specification_asset",
assetJson(evidence.specificationAsset)
@@ -177,7 +208,7 @@ private class CandidateEvidenceStore(context: Context) {
)
}
val manifest = JSONObject()
.put("schema_version", 2)
.put("schema_version", 3)
.put("candidate_count", evidenceByOrdinal.size)
.put("candidates", candidates)
.toString(2)
@@ -35,8 +35,23 @@ class CandidateEvidenceSource(
var totalBytes = 0L
sorted.map { metadata ->
require(
metadata.specification.groups.map { it.kind }.toSet() ==
PinduoduoSpecificationGroupKind.entries.toSet()
metadata.specification.groups.isNotEmpty() &&
metadata.specification.groups
.map { it.kind }
.distinct()
.size == metadata.specification.groups.size
)
require(
metadata.specification.selectedSummary
?.length
?.let { it in 1..160 } != false
)
require(
metadata.specification.price?.let { price ->
price.rawText.length in 1..32 &&
price.cents in 1..100_000_000L &&
!metadata.specification.priceAmbiguous
} != false
)
val detail = readAsset(
canonicalRoot = canonicalRoot,
@@ -19,6 +19,9 @@ enum class CandidateBrowsePhase {
CAPTURING_DETAIL,
OPENING_SPECIFICATIONS,
READING_SPECIFICATIONS,
SELECTING_COLOR,
SELECTING_SIZE,
SCROLLING_SPECIFICATIONS,
CLOSING_SPECIFICATIONS,
RETURNING_RESULTS,
SCROLLING_RESULTS,
@@ -27,8 +30,10 @@ enum class CandidateBrowsePhase {
class PinduoduoCandidateAutomation(
private val driver: PinduoduoCandidateDriver,
private val specificationTarget: PinduoduoSpecificationTarget? = null,
private val maxCandidates: Int = MAX_CANDIDATES_PER_PROBE,
private val maxResultScrolls: Int = MAX_RESULT_SCROLLS_PER_PROBE,
private val maxSpecificationScrolls: Int = 2,
private val pagePollIntervalMillis: Long = 200,
private val unknownPageLimit: Int = 20
) : AutomationGateway {
@@ -45,6 +50,7 @@ class PinduoduoCandidateAutomation(
init {
require(maxCandidates in 1..MAX_CANDIDATES_PER_PROBE)
require(maxResultScrolls in 0..MAX_RESULT_SCROLLS_PER_PROBE)
require(maxSpecificationScrolls in 0..2)
require(pagePollIntervalMillis > 0)
require(unknownPageLimit > 0)
}
@@ -110,11 +116,33 @@ class PinduoduoCandidateAutomation(
)
mutablePhase.value = CandidateBrowsePhase.OPENING_SPECIFICATIONS
if (!driver.openSpecifications()) {
return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
returnToResultsAfterUnsupportedCandidate()?.let { return it }
continue
}
when (val entry = awaitSpecificationEntry()) {
SpecificationEntry.PANEL -> Unit
SpecificationEntry.ORDER_CONFIRMATION -> {
if (!driver.returnFromOrderConfirmation()) {
return AutomationResult.Blocked(
SafetyStopReason.PAYMENT_BOUNDARY
)
}
awaitPage(PinduoduoPage.PRODUCT_DETAIL)?.let { return it }
returnToResultsAfterUnsupportedCandidate()?.let {
return it
}
continue
}
is SpecificationEntry.FAILED -> return entry.result
}
awaitPage(PinduoduoPage.SPECIFICATION_PANEL)?.let { return it }
mutablePhase.value = CandidateBrowsePhase.READING_SPECIFICATIONS
val specifications = driver.captureSpecifications()
val specificationEvidence = collectSpecificationEvidence()
?: return AutomationResult.FatalFailure(
WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED
)
val specifications = driver.captureSpecifications(
specificationEvidence
)
?: return AutomationResult.FatalFailure(
WorkflowFailureCode.EVIDENCE_CAPTURE_FAILED
)
@@ -146,6 +174,157 @@ class PinduoduoCandidateAutomation(
return terminalCollectionResult()
}
private suspend fun collectSpecificationEvidence():
PinduoduoSpecificationEvidence? {
val observations = mutableListOf<PinduoduoSpecificationEvidence>()
var specificationScrolls = 0
suspend fun read(): PinduoduoSpecificationEvidence? =
driver.readSpecifications()?.also(observations::add)
if (specificationTarget == null) {
read()
return PinduoduoSpecificationParser.merge(observations)
}
val targets = listOf(
PinduoduoSpecificationGroupKind.COLOR to
specificationTarget.color,
PinduoduoSpecificationGroupKind.SIZE to
specificationTarget.size
)
for ((kind, expected) in targets) {
var resolved = false
while (!resolved) {
val current = read()
?: return PinduoduoSpecificationParser.merge(observations)
when (
val resolution =
PinduoduoSpecificationSelectionPolicy.resolve(
evidence = current,
kind = kind,
expected = expected
)
) {
is PinduoduoSpecificationOptionResolution.Ready -> {
if (resolution.alreadySelected) {
resolved = true
continue
}
mutablePhase.value = when (kind) {
PinduoduoSpecificationGroupKind.COLOR ->
CandidateBrowsePhase.SELECTING_COLOR
PinduoduoSpecificationGroupKind.SIZE ->
CandidateBrowsePhase.SELECTING_SIZE
}
if (
!driver.selectSpecificationOption(
kind,
resolution.optionText
)
) {
return PinduoduoSpecificationParser.merge(
observations
)
}
delay(pagePollIntervalMillis)
awaitPage(PinduoduoPage.SPECIFICATION_PANEL)?.let {
return PinduoduoSpecificationParser.merge(
observations
)
}
val verified = read()
?: return PinduoduoSpecificationParser.merge(
observations
)
resolved =
(
PinduoduoSpecificationSelectionPolicy.resolve(
evidence = verified,
kind = kind,
expected = expected
) as? PinduoduoSpecificationOptionResolution.Ready
)?.alreadySelected == true
if (!resolved) {
return PinduoduoSpecificationParser.merge(
observations
)
}
}
PinduoduoSpecificationOptionResolution.Absent -> {
if (specificationScrolls >= maxSpecificationScrolls) {
return PinduoduoSpecificationParser.merge(
observations
)
}
mutablePhase.value =
CandidateBrowsePhase.SCROLLING_SPECIFICATIONS
if (!driver.scrollSpecifications()) {
return PinduoduoSpecificationParser.merge(
observations
)
}
specificationScrolls += 1
delay(pagePollIntervalMillis)
awaitPage(PinduoduoPage.SPECIFICATION_PANEL)?.let {
return PinduoduoSpecificationParser.merge(
observations
)
}
}
PinduoduoSpecificationOptionResolution.Ambiguous,
is PinduoduoSpecificationOptionResolution.Disabled ->
return PinduoduoSpecificationParser.merge(observations)
}
}
}
mutablePhase.value = CandidateBrowsePhase.READING_SPECIFICATIONS
read()
return PinduoduoSpecificationParser.merge(observations)
}
private suspend fun awaitSpecificationEntry(): SpecificationEntry {
var stableUnexpectedObservations = 0
while (true) {
val snapshot = driver.snapshot()
if (snapshot.page == PinduoduoPage.SPECIFICATION_PANEL) {
return SpecificationEntry.PANEL
}
if (snapshot.page == PinduoduoPage.ORDER_CONFIRMATION) {
return SpecificationEntry.ORDER_CONFIRMATION
}
safetyResult(snapshot)?.let {
return SpecificationEntry.FAILED(it)
}
stableUnexpectedObservations = if (
snapshot.foregroundPackage == PINDUODUO_PACKAGE
) {
stableUnexpectedObservations + 1
} else {
0
}
if (stableUnexpectedObservations >= unknownPageLimit) {
return SpecificationEntry.FAILED(
AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
)
}
delay(pagePollIntervalMillis)
}
}
private suspend fun returnToResultsAfterUnsupportedCandidate():
AutomationResult? {
mutablePhase.value = CandidateBrowsePhase.RETURNING_RESULTS
if (!driver.returnToResults()) {
return AutomationResult.RetryableFailure(
WorkflowFailureCode.TRANSIENT_AUTOMATION
)
}
val result = awaitResultsPage()
mutablePhase.value = CandidateBrowsePhase.READING_RESULTS
return result
}
private suspend fun recoverDetailPageIfNeeded(): AutomationResult? {
val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it }
@@ -237,6 +416,14 @@ class PinduoduoCandidateAutomation(
private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? =
snapshot.safetyStopReason?.let(AutomationResult::Blocked)
private sealed interface SpecificationEntry {
data object PANEL : SpecificationEntry
data object ORDER_CONFIRMATION : SpecificationEntry
data class FAILED(
val result: AutomationResult
) : SpecificationEntry
}
}
class PinduoduoProbeAutomation(
@@ -58,8 +58,17 @@ interface PinduoduoCandidateDriver {
suspend fun openCandidate(signature: String): Boolean
suspend fun captureDetail(): PinduoduoCandidateCapture?
suspend fun openSpecifications(): Boolean
suspend fun captureSpecifications(): PinduoduoSpecificationCapture?
suspend fun readSpecifications(): PinduoduoSpecificationEvidence?
suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean
suspend fun scrollSpecifications(): Boolean
suspend fun captureSpecifications(
evidence: PinduoduoSpecificationEvidence
): PinduoduoSpecificationCapture?
suspend fun closeSpecifications(): Boolean
suspend fun returnFromOrderConfirmation(): Boolean
suspend fun saveCandidate(
ordinal: Int,
card: PinduoduoCandidateCard,
@@ -17,7 +17,9 @@ data class PinduoduoUiElement(
val selected: Boolean = false,
val scrollable: Boolean = false,
val boundsLeft: Int = 0,
val boundsTop: Int = 0
val boundsTop: Int = 0,
val boundsRight: Int = 0,
val boundsBottom: Int = 0
)
enum class PinduoduoPage {
@@ -29,6 +31,8 @@ enum class PinduoduoPage {
IMAGE_SEARCH_RESULTS,
PRODUCT_DETAIL,
SPECIFICATION_PANEL,
ORDER_CONFIRMATION,
ORDER_LIST,
UNKNOWN
}
@@ -159,10 +163,33 @@ object PinduoduoPageClassifier {
paymentMarkers.none(semantic::contains)
}
val hasSpecificationPanel =
specificationGroupCount >= 2 &&
specificationOptionCount >= 2
(
normalized.any { it == "确认款式" } &&
normalized.any { it.startsWith("已选择") } &&
visibleElements.any { element ->
element.clickable &&
normalize(
element.contentDescription.orEmpty()
) == "关闭"
} &&
visibleElements.any { element ->
element.clickable &&
normalize(element.text.orEmpty()) == "确定"
}
) ||
(
specificationGroupCount >= 2 &&
specificationOptionCount >= 2
)
val hasOrderConfirmation = normalized.any { it == "确认订单" }
val hasOrderList =
normalized.any { it == "我的订单" || it == "全部订单" } &&
setOf("待付款", "待发货", "待收货")
.count { marker -> normalized.any { it == marker } } >= 2
val page = when {
hasOrderConfirmation -> PinduoduoPage.ORDER_CONFIRMATION
hasOrderList -> PinduoduoPage.ORDER_LIST
hasSpecificationPanel -> PinduoduoPage.SPECIFICATION_PANEL
hasImageResultHeader && legacySortControlCount >= 3 ->
PinduoduoPage.IMAGE_SEARCH_RESULTS
@@ -1,5 +1,7 @@
package com.roubao.autopilot.pinduoduo
import java.text.Normalizer
enum class PinduoduoSpecificationGroupKind {
COLOR,
SIZE
@@ -21,9 +23,34 @@ data class PinduoduoSpecificationGroup(
data class PinduoduoSpecificationEvidence(
val signature: String,
val semanticTextCount: Int,
val groups: List<PinduoduoSpecificationGroup>
val groups: List<PinduoduoSpecificationGroup>,
val selectedSummary: String? = null,
val price: PinduoduoSpecificationPrice? = null,
val priceAmbiguous: Boolean = false
)
data class PinduoduoSpecificationPrice(
val rawText: String,
val cents: Long
)
data class PinduoduoSpecificationTarget(
val color: String,
val size: String
)
sealed interface PinduoduoSpecificationOptionResolution {
data object Absent : PinduoduoSpecificationOptionResolution
data object Ambiguous : PinduoduoSpecificationOptionResolution
data class Disabled(
val optionText: String
) : PinduoduoSpecificationOptionResolution
data class Ready(
val optionText: String,
val alreadySelected: Boolean
) : PinduoduoSpecificationOptionResolution
}
object PinduoduoSpecificationParser {
fun parse(
elements: Collection<PinduoduoUiElement>
@@ -47,10 +74,7 @@ object PinduoduoSpecificationParser {
GroupHeader(index, kind, semanticText(element), element.boundsTop)
}
}
if (
headers.map { it.kind }.toSet() !=
PinduoduoSpecificationGroupKind.entries.toSet()
) {
if (headers.isEmpty()) {
return null
}
@@ -77,8 +101,8 @@ object PinduoduoSpecificationParser {
options.size > MAX_OPTIONS_PER_GROUP ||
ordered.any { element ->
element.scrollable &&
element.boundsTop >= header.top &&
element.boundsTop < nextTop
element.boundsTop <= header.top &&
element.boundsBottom > header.top
}
PinduoduoSpecificationGroup(
kind = header.kind,
@@ -87,10 +111,22 @@ object PinduoduoSpecificationParser {
complete = !clipped
)
}
if (groups.any { it.options.isEmpty() }) {
if (groups.all { it.options.isEmpty() }) {
return null
}
val selectedSummary = ordered.asSequence()
.map(::semanticText)
.firstOrNull { normalize(it).startsWith("已选择") }
?.take(MAX_SELECTED_SUMMARY_LENGTH)
val observedPrices = ordered.asSequence()
.map(::semanticText)
.mapNotNull(::parsePrice)
.distinctBy { it.cents }
.take(MAX_PRICE_CANDIDATES + 1)
.toList()
val price = observedPrices.singleOrNull()
val priceAmbiguous = observedPrices.size > 1
val semantics = buildList {
groups.forEach { group ->
add("${group.kind.name}:${normalize(group.title)}:${group.complete}")
@@ -101,13 +137,78 @@ object PinduoduoSpecificationParser {
)
}
}
selectedSummary?.let { add("selected:${normalize(it)}") }
price?.let { add("price:${it.cents}") }
add("price_ambiguous:$priceAmbiguous")
}
return PinduoduoSpecificationEvidence(
signature = PinduoduoEvidenceHash.sha256(
semantics.joinToString("\u001f")
),
semanticTextCount = semantics.size,
groups = groups
groups = groups,
selectedSummary = selectedSummary,
price = price,
priceAmbiguous = priceAmbiguous
)
}
fun merge(
observations: List<PinduoduoSpecificationEvidence>
): PinduoduoSpecificationEvidence? {
if (observations.isEmpty()) {
return null
}
val groups = PinduoduoSpecificationGroupKind.entries.mapNotNull { kind ->
val observedGroups = observations.flatMap { evidence ->
evidence.groups.filter { it.kind == kind }
}
if (observedGroups.isEmpty()) {
return@mapNotNull null
}
val optionsByText =
linkedMapOf<String, PinduoduoSpecificationOption>()
observedGroups.forEach { group ->
group.options.forEach { option ->
optionsByText[normalize(option.text)] = option
}
}
PinduoduoSpecificationGroup(
kind = kind,
title = observedGroups.first().title,
options = optionsByText.values.toList(),
complete = observedGroups.all { it.complete }
)
}
val prices = observations.mapNotNull { it.price }
.distinctBy { it.cents }
val priceAmbiguous =
observations.any { it.priceAmbiguous } || prices.size > 1
val selectedSummary = observations.asReversed()
.firstNotNullOfOrNull { it.selectedSummary }
val semantics = buildList {
groups.forEach { group ->
add("${group.kind.name}:${group.complete}")
group.options.forEach { option ->
add(
"${normalize(option.text)}:" +
"${option.selected}:${option.enabled}"
)
}
}
selectedSummary?.let { add("selected:${normalize(it)}") }
prices.singleOrNull()?.let { add("price:${it.cents}") }
add("price_ambiguous:$priceAmbiguous")
}
return PinduoduoSpecificationEvidence(
signature = PinduoduoEvidenceHash.sha256(
semantics.joinToString("\u001f")
),
semanticTextCount = semantics.size,
groups = groups,
selectedSummary = selectedSummary,
price = prices.singleOrNull().takeUnless { priceAmbiguous },
priceAmbiguous = priceAmbiguous
)
}
@@ -124,6 +225,7 @@ object PinduoduoSpecificationParser {
normalized !in SAFE_ENTRY_TEXTS &&
SAFE_ENTRY_PREFIXES.none(normalized::startsWith) &&
TRANSACTION_MARKERS.none(normalized::contains) &&
normalized !in SPECIFICATION_ACTION_MARKERS &&
(element.clickable || element.selected || !element.enabled)
}
@@ -146,6 +248,25 @@ object PinduoduoSpecificationParser {
private fun normalize(value: String): String =
value.trim().lowercase().replace(Regex("\\s+"), "")
private fun parsePrice(value: String): PinduoduoSpecificationPrice? {
val match = PRICE_PATTERN.matchEntire(
Normalizer.normalize(value.trim(), Normalizer.Form.NFKC)
.replace(Regex("\\s+"), "")
) ?: return null
val whole = match.groupValues[1].toLongOrNull() ?: return null
val fraction = match.groupValues[2].padEnd(2, '0')
.ifEmpty { "00" }
.toLongOrNull() ?: return null
val cents = whole * 100 + fraction
if (cents <= 0 || cents > MAX_PRICE_CENTS) {
return null
}
return PinduoduoSpecificationPrice(
rawText = value.trim().take(MAX_PRICE_TEXT_LENGTH),
cents = cents
)
}
private data class GroupHeader(
val index: Int,
val kind: PinduoduoSpecificationGroupKind,
@@ -172,7 +293,113 @@ object PinduoduoSpecificationParser {
"支付",
"结算"
)
private val SPECIFICATION_ACTION_MARKERS = setOf(
"查看大图",
"增加数量",
"减少数量",
"确定",
"关闭",
"确认款式"
)
private val PRICE_PATTERN = Regex("""^[¥¥](\d{1,7})(?:\.(\d{1,2}))?$""")
private const val MAX_ELEMENTS = 160
private const val MAX_OPTIONS_PER_GROUP = 30
private const val MAX_OPTION_TEXT_LENGTH = 80
private const val MAX_SELECTED_SUMMARY_LENGTH = 160
private const val MAX_PRICE_CANDIDATES = 4
private const val MAX_PRICE_TEXT_LENGTH = 32
private const val MAX_PRICE_CENTS = 100_000_000L
}
object PinduoduoSpecificationSelectionPolicy {
fun resolve(
evidence: PinduoduoSpecificationEvidence,
kind: PinduoduoSpecificationGroupKind,
expected: String
): PinduoduoSpecificationOptionResolution {
val groups = evidence.groups.filter { it.kind == kind }
if (groups.size != 1) {
return if (groups.isEmpty()) {
PinduoduoSpecificationOptionResolution.Absent
} else {
PinduoduoSpecificationOptionResolution.Ambiguous
}
}
val matches = groups.single().options.filter { option ->
PinduoduoSpecificationTargetMatcher.matches(
kind = kind,
expected = expected,
observed = option.text
)
}
return when {
matches.isEmpty() -> PinduoduoSpecificationOptionResolution.Absent
matches.size > 1 -> PinduoduoSpecificationOptionResolution.Ambiguous
!matches.single().enabled ->
PinduoduoSpecificationOptionResolution.Disabled(
matches.single().text
)
else -> PinduoduoSpecificationOptionResolution.Ready(
optionText = matches.single().text,
alreadySelected = matches.single().selected
)
}
}
}
object PinduoduoSpecificationTargetMatcher {
fun matches(
kind: PinduoduoSpecificationGroupKind,
expected: String,
observed: String
): Boolean {
val normalized = Normalizer.normalize(
observed.trim(),
Normalizer.Form.NFKC
).uppercase()
return when (kind) {
PinduoduoSpecificationGroupKind.COLOR ->
COLOR_ALIASES[expected].orEmpty()
.any { alias -> normalized.matchesAlias(alias) }
PinduoduoSpecificationGroupKind.SIZE ->
sizeAliases(expected).any { alias ->
Regex(
"""(?:^|[^A-Z0-9])${Regex.escape(alias)}""" +
"""(?=$|[^A-Z0-9])"""
).containsMatchIn(normalized)
}
}
}
private fun String.matchesAlias(alias: String): Boolean =
if (alias.all { it in 'A'..'Z' }) {
Regex(
"""(?:^|[^A-Z])${Regex.escape(alias)}(?=$|[^A-Z])"""
).containsMatchIn(this)
} else {
contains(alias)
}
private fun sizeAliases(expected: String): Set<String> =
when (expected) {
"2XL" -> setOf("2XL", "XXL")
"3XL" -> setOf("3XL", "XXXL")
"FREE" -> setOf("FREE", "FREESIZE", "均码")
else -> setOf(expected)
}
private val COLOR_ALIASES = mapOf(
"BLACK" to listOf("BLACK", "黑色", "亮黑", "纯黑"),
"WHITE" to listOf("WHITE", "白色", "纯白", "米白"),
"GRAY" to listOf("GRAY", "GREY", "灰色", "浅灰", "深灰"),
"RED" to listOf("RED", "红色", "酒红", "玫红"),
"BLUE" to listOf("BLUE", "蓝色", "藏青", "牛仔蓝"),
"GREEN" to listOf("GREEN", "绿色", "军绿"),
"YELLOW" to listOf("YELLOW", "黄色"),
"PINK" to listOf("PINK", "粉色", "粉红"),
"PURPLE" to listOf("PURPLE", "紫色"),
"BROWN" to listOf("BROWN", "棕色", "咖色", "咖啡色"),
"BEIGE" to listOf("BEIGE", "米色", "卡其"),
"ORANGE" to listOf("ORANGE", "橙色")
)
}
@@ -2,6 +2,7 @@ package com.roubao.autopilot.vlm
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTargetMatcher
import java.text.Normalizer
enum class SkuConstraintKind {
@@ -107,8 +108,21 @@ object CandidateSpecificationHardConstraintPolicy {
constraints: List<SkuHardConstraint>,
evidence: PinduoduoSpecificationEvidence,
evidenceSha256: String
): List<CandidateHardConstraintResult> =
constraints.map { constraint ->
): List<CandidateHardConstraintResult> {
if (evidence.price == null || evidence.priceAmbiguous) {
return constraints.map { constraint ->
result(
constraint,
HardConstraintMatchStatus.UNKNOWN,
audit(
constraint,
"combination_price_unverified",
evidenceSha256
)
)
}
}
return constraints.map { constraint ->
val kind = when (constraint.kind) {
SkuConstraintKind.COLOR ->
PinduoduoSpecificationGroupKind.COLOR
@@ -125,7 +139,11 @@ object CandidateSpecificationHardConstraintPolicy {
}
val group = matchingGroups.single()
val matchingOptions = group.options.filter { option ->
optionMatches(constraint, option.text)
PinduoduoSpecificationTargetMatcher.matches(
kind = kind,
expected = constraint.expected,
observed = option.text
)
}
when {
matchingOptions.size > 1 ->
@@ -134,11 +152,21 @@ object CandidateSpecificationHardConstraintPolicy {
HardConstraintMatchStatus.UNKNOWN,
audit(constraint, "target_duplicate", evidenceSha256)
)
matchingOptions.singleOrNull()?.enabled == true ->
matchingOptions.singleOrNull()?.let { option ->
option.enabled &&
option.selected &&
evidence.selectedSummary?.let { summary ->
PinduoduoSpecificationTargetMatcher.matches(
kind = kind,
expected = constraint.expected,
observed = summary
)
} == true
} == true ->
result(
constraint,
HardConstraintMatchStatus.MATCH,
audit(constraint, "target_enabled", evidenceSha256)
audit(constraint, "target_selected", evidenceSha256)
)
matchingOptions.singleOrNull()?.enabled == false ->
result(
@@ -146,6 +174,16 @@ object CandidateSpecificationHardConstraintPolicy {
HardConstraintMatchStatus.MISMATCH,
audit(constraint, "target_disabled", evidenceSha256)
)
matchingOptions.singleOrNull()?.enabled == true ->
result(
constraint,
HardConstraintMatchStatus.UNKNOWN,
audit(
constraint,
"target_not_selected",
evidenceSha256
)
)
group.complete ->
result(
constraint,
@@ -160,6 +198,7 @@ object CandidateSpecificationHardConstraintPolicy {
)
}
}
}
private fun result(
constraint: SkuHardConstraint,
@@ -180,57 +219,4 @@ object CandidateSpecificationHardConstraintPolicy {
"group=${constraint.kind.name};target=${constraint.expected};" +
"observation=$observation;spec_sha256=$evidenceSha256"
private fun optionMatches(
constraint: SkuHardConstraint,
observed: String
): Boolean {
val normalized = Normalizer.normalize(
observed.trim(),
Normalizer.Form.NFKC
).uppercase()
return when (constraint.kind) {
SkuConstraintKind.COLOR ->
COLOR_ALIASES[constraint.expected].orEmpty()
.any { alias -> normalized.matchesAlias(alias) }
SkuConstraintKind.SIZE ->
sizeAliases(constraint.expected).any { alias ->
Regex(
"""(?:^|[^A-Z0-9])${Regex.escape(alias)}""" +
"""(?=$|[^A-Z0-9])"""
).containsMatchIn(normalized)
}
}
}
private fun String.matchesAlias(alias: String): Boolean =
if (alias.all { it in 'A'..'Z' }) {
Regex(
"""(?:^|[^A-Z])${Regex.escape(alias)}(?=$|[^A-Z])"""
).containsMatchIn(this)
} else {
contains(alias)
}
private fun sizeAliases(expected: String): Set<String> =
when (expected) {
"2XL" -> setOf("2XL", "XXL")
"3XL" -> setOf("3XL", "XXXL")
"FREE" -> setOf("FREE", "FREESIZE", "均码")
else -> setOf(expected)
}
private val COLOR_ALIASES = mapOf(
"BLACK" to listOf("BLACK", "黑色", "亮黑", "纯黑"),
"WHITE" to listOf("WHITE", "白色", "纯白", "米白"),
"GRAY" to listOf("GRAY", "GREY", "灰色", "浅灰", "深灰"),
"RED" to listOf("RED", "红色", "酒红", "玫红"),
"BLUE" to listOf("BLUE", "蓝色", "藏青", "牛仔蓝"),
"GREEN" to listOf("GREEN", "绿色", "军绿"),
"YELLOW" to listOf("YELLOW", "黄色"),
"PINK" to listOf("PINK", "粉色", "粉红"),
"PURPLE" to listOf("PURPLE", "紫色"),
"BROWN" to listOf("BROWN", "棕色", "咖色", "咖啡色"),
"BEIGE" to listOf("BEIGE", "米色", "卡其"),
"ORANGE" to listOf("ORANGE", "橙色")
)
}
@@ -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
)
}