feat(t213): verify selected SKU combination price
This commit is contained in:
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -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
|
||||
|
||||
+122
-4
@@ -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*$")
|
||||
}
|
||||
|
||||
+35
-4
@@ -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)
|
||||
|
||||
+17
-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,
|
||||
|
||||
+190
-3
@@ -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(
|
||||
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+30
-3
@@ -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
|
||||
|
||||
+236
-9
@@ -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", "橙色")
|
||||
)
|
||||
}
|
||||
|
||||
+270
-22
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -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(
|
||||
|
||||
+164
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+40
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user