Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8382e6518 | ||
|
|
0edc78a943 | ||
|
|
5e0a9d108c |
+229
-10
@@ -90,7 +90,28 @@ data class VisibleSpecValue(
|
|||||||
val rawText: String = text,
|
val rawText: String = text,
|
||||||
)
|
)
|
||||||
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
||||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
|
enum class SpecPanelType {
|
||||||
|
UNKNOWN,
|
||||||
|
NORMAL_SCROLLABLE,
|
||||||
|
NON_SCROLLABLE_CONFIRMATION,
|
||||||
|
QUICK_CONFIRMATION,
|
||||||
|
ORDER_CONFIRMATION,
|
||||||
|
/**
|
||||||
|
* #331 fallback: only reached when no stronger panel type matched. It
|
||||||
|
* requires an address entry, a payment-method entry and one quantity input
|
||||||
|
* box, plus at least one auxiliary signal. It is recognition only and never
|
||||||
|
* relaxes any click target (the final order-submit click stays strict).
|
||||||
|
*/
|
||||||
|
REQUIRED_EVIDENCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #331 bounded wait for a spec panel that is still loading after an entry click or an address save. */
|
||||||
|
object SpecPanelStabilityPolicy {
|
||||||
|
/** Upper bound for waiting until the required evidence is present and the structure is stable. */
|
||||||
|
const val WAIT_BOUND_MS = 5_000L
|
||||||
|
/** Sampling interval used after an address save returns to the purchase panel. */
|
||||||
|
const val ADDRESS_SAVE_POLL_MS = 200L
|
||||||
|
}
|
||||||
|
|
||||||
object PddSoldOutRecoveryDefaults {
|
object PddSoldOutRecoveryDefaults {
|
||||||
const val EXACT_TEXT = "商品已售罄"
|
const val EXACT_TEXT = "商品已售罄"
|
||||||
@@ -134,7 +155,54 @@ data class ParsedPddScreen(
|
|||||||
val hasCloseControl: Boolean = false,
|
val hasCloseControl: Boolean = false,
|
||||||
val hasPaymentArea: Boolean = false,
|
val hasPaymentArea: Boolean = false,
|
||||||
val purchaseContextMatched: Boolean = false,
|
val purchaseContextMatched: Boolean = false,
|
||||||
|
// #331 evidence flags. Presence only: address text is never kept here.
|
||||||
|
val hasAddressEntry: Boolean = false,
|
||||||
|
val hasPaymentEntry: Boolean = false,
|
||||||
|
val hasQuantityInput: Boolean = false,
|
||||||
|
val hasQuantityAdjustControls: Boolean = false,
|
||||||
|
val quantityInputCount: Int = 0,
|
||||||
|
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||||
|
val requiredEvidenceSameContainer: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
/** The three #331 required spec-panel items are all visible. */
|
||||||
|
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||||
|
|
||||||
|
/** Boolean/count-only evidence string for diagnostics; contains no page text. */
|
||||||
|
fun requiredEvidenceSummary(): String =
|
||||||
|
"addr=${hasAddressEntry.flag()};pay=${hasPaymentEntry.flag()};qin=$quantityInputCount;" +
|
||||||
|
"adj=${hasQuantityAdjustControls.flag()};summary=${hasSelectionSummary.flag()};" +
|
||||||
|
"close=${hasCloseControl.flag()};submit=${hasOrderSubmitAction.flag()};same=${requiredEvidenceSameContainer.flag()}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #331 purchaser-readable hints for missing required items. Only emitted
|
||||||
|
* when the page shows at least one required item or parsed options, so an
|
||||||
|
* unrelated page does not produce misleading environment hints. No page
|
||||||
|
* text or personal data is included.
|
||||||
|
*/
|
||||||
|
fun requiredEvidenceHints(): List<String> {
|
||||||
|
val partial = hasAddressEntry || hasPaymentEntry || hasQuantityInput || panelOptionCount > 0
|
||||||
|
if (!partial) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
if (!hasPaymentEntry) add(MISSING_PAYMENT_ENTRY_HINT)
|
||||||
|
if (!hasAddressEntry) add(MISSING_ADDRESS_ENTRY_HINT)
|
||||||
|
if (!hasQuantityInput) add(MISSING_QUANTITY_INPUT_HINT)
|
||||||
|
if (hasRequiredPanelEvidence && !requiredEvidenceSameContainer) add(NOT_SAME_CONTAINER_HINT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hints joined for a failure message with a leading separator; empty when there is nothing to hint. */
|
||||||
|
fun requiredEvidenceHintSuffix(): String =
|
||||||
|
requiredEvidenceHints().takeIf { it.isNotEmpty() }?.joinToString(";", prefix = ";") ?: ""
|
||||||
|
|
||||||
|
private fun Boolean.flag(): Int = if (this) 1 else 0
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MISSING_PAYMENT_ENTRY_HINT = "未找到支付入口:请确认 PDD 默认支付方式为微信支付"
|
||||||
|
const val MISSING_ADDRESS_ENTRY_HINT = "未找到收货地址入口:请确认 PDD 已设置默认收货地址"
|
||||||
|
const val MISSING_QUANTITY_INPUT_HINT = "未找到购买数量输入框"
|
||||||
|
const val NOT_SAME_CONTAINER_HINT = "收货地址入口、支付入口和购买数量输入框不在同一规格面板内"
|
||||||
|
}
|
||||||
|
|
||||||
fun isTransientSoldOut(
|
fun isTransientSoldOut(
|
||||||
exactText: String,
|
exactText: String,
|
||||||
fallbackTopText: String = "相似商品",
|
fallbackTopText: String = "相似商品",
|
||||||
@@ -186,6 +254,13 @@ object PddScreenParser {
|
|||||||
// purchase/order/payment controls must never become collection click targets.
|
// purchase/order/payment controls must never become collection click targets.
|
||||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||||
|
|
||||||
|
/** Masked CN mobile number shown in the purchase address row, e.g. 138****5678. Presence only. */
|
||||||
|
val MASKED_PHONE_PATTERN = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||||
|
/** An address/payment entry must be clickable itself or through a nearby row container. */
|
||||||
|
private const val ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH = 6
|
||||||
|
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
|
||||||
|
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
|
||||||
|
|
||||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
||||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||||
val visible = visibleNodes.mapNotNull { node ->
|
val visible = visibleNodes.mapNotNull { node ->
|
||||||
@@ -203,6 +278,8 @@ object PddScreenParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val labels = visible.map(SnapshotNode::label)
|
val labels = visible.map(SnapshotNode::label)
|
||||||
|
val visibleByPath = visible.associateBy(SnapshotNode::path)
|
||||||
|
val sourceByPath = visibleNodes.associateBy(SnapshotNode::path)
|
||||||
val textAliases = config.textAliases
|
val textAliases = config.textAliases
|
||||||
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
||||||
val compactLabels = labels.map { it.replace(" ", "") }
|
val compactLabels = labels.map { it.replace(" ", "") }
|
||||||
@@ -240,7 +317,23 @@ object PddScreenParser {
|
|||||||
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
|
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
|
||||||
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
|
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
|
||||||
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
|
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
|
||||||
|
// #331: the unique input box is required; the +/- buttons are only auxiliary.
|
||||||
|
val hasQuantityInput = quantityInputs.size == 1 && quantityInputs.single().enabled
|
||||||
|
val hasQuantityAdjustControls = decreaseControls.size == 1 && increaseControls.size == 1
|
||||||
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
|
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
|
||||||
|
val addressEntryRows = addressEntryRows(visibleNodes, screenHeight)
|
||||||
|
val paymentEntryRows = paymentEntryRows(visibleNodes, screenHeight, textAliases.specPanel.paymentAreaAliases)
|
||||||
|
val hasAddressEntry = addressEntryRows.isNotEmpty()
|
||||||
|
val hasPaymentEntry = paymentEntryRows.isNotEmpty()
|
||||||
|
// #331: the three required items must sit in one panel container, not
|
||||||
|
// merely somewhere on screen (e.g. an address dialog, a payment dialog
|
||||||
|
// and an unrelated input box). Same area bound idea as boundedScrollables.
|
||||||
|
val requiredEvidenceSameContainer = hasAddressEntry && hasPaymentEntry && hasQuantityInput &&
|
||||||
|
addressEntryRows.any { address ->
|
||||||
|
paymentEntryRows.any { payment ->
|
||||||
|
sharesBoundedPanelContainer(listOf(address, payment, quantityInputs.single()), sourceByPath, screenArea)
|
||||||
|
}
|
||||||
|
}
|
||||||
val hasQuickBuy = visible.any { node ->
|
val hasQuickBuy = visible.any { node ->
|
||||||
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
|
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
|
||||||
node.bounds.centerY.toDouble() >= screenHeight * 0.75
|
node.bounds.centerY.toDouble() >= screenHeight * 0.75
|
||||||
@@ -276,27 +369,50 @@ object PddScreenParser {
|
|||||||
headings.forEachIndexed { index, heading ->
|
headings.forEachIndexed { index, heading ->
|
||||||
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
||||||
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
|
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
|
||||||
val values = panelVisible.asSequence()
|
fun inValueRegion(node: SnapshotNode) =
|
||||||
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
|
node.bounds.top >= heading.bounds.bottom && node.bounds.bottom <= lower &&
|
||||||
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
|
node.bounds.width > 0 && node.bounds.height > 0 && node.label.length <= 80
|
||||||
|
val candidates = panelVisible.asSequence()
|
||||||
|
.filter { it.clickable && inValueRegion(it) }
|
||||||
.filterNot { isExactHeadingLabel(it.label, config) }
|
.filterNot { isExactHeadingLabel(it.label, config) }
|
||||||
.filterNot { node ->
|
.filterNot { node ->
|
||||||
isExcludedOptionLabel(node.label) ||
|
isExcludedOptionLabel(node.label) ||
|
||||||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
||||||
}
|
}
|
||||||
.map {
|
.toList()
|
||||||
val rawText = it.label
|
// #332/task 535: one option block may expose several clickable
|
||||||
|
// nodes (outer block, image, inner text with a badge such as
|
||||||
|
// "零差评"). Nodes nested in the same labelled option block are
|
||||||
|
// collapsed into one value named by the outer block. The click
|
||||||
|
// node is still chosen by safeOptionRank among the members.
|
||||||
|
val values = candidates
|
||||||
|
.groupBy { candidate ->
|
||||||
|
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
|
||||||
|
}
|
||||||
|
.map { (blockPath, members) ->
|
||||||
|
val block = visibleByPath[blockPath]
|
||||||
|
val chosen = members.minBy { option -> safeOptionRank(option, visibleNodes) }
|
||||||
|
val selected = members.any(SnapshotNode::selected) || block?.selected == true
|
||||||
|
val checked = members.any(SnapshotNode::checked) || block?.checked == true
|
||||||
|
Triple(chosen.copy(selected = selected, checked = checked), block?.label ?: chosen.label, members)
|
||||||
|
}
|
||||||
|
.asSequence()
|
||||||
|
.map { (option, blockLabel, members) ->
|
||||||
|
val rawText = blockLabel
|
||||||
val normalizedText = when (dimensionKey) {
|
val normalizedText = when (dimensionKey) {
|
||||||
"color" -> SpecValueNormalizer.normalizeColor(rawText)
|
"color" -> SpecValueNormalizer.normalizeColor(rawText)
|
||||||
"size" -> SpecValueNormalizer.normalizeSize(rawText)
|
"size" -> SpecValueNormalizer.normalizeSize(rawText)
|
||||||
else -> rawText
|
else -> rawText
|
||||||
}
|
}
|
||||||
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
val stateText = (listOf(option.label) + descendants(option, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||||
VisibleSpecValue(
|
VisibleSpecValue(
|
||||||
normalizedText,
|
normalizedText,
|
||||||
it.enabled && !stateText.containsUnavailableWord(),
|
option.enabled && !stateText.containsUnavailableWord(),
|
||||||
it,
|
option,
|
||||||
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
|
if (dimensionKey == "color") {
|
||||||
|
(listOf(option) + members.filter { it.path != option.path })
|
||||||
|
.firstNotNullOfOrNull { colorImageBoundsForOption(it, visibleNodes) }
|
||||||
|
} else null,
|
||||||
rawText,
|
rawText,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -339,6 +455,14 @@ object PddScreenParser {
|
|||||||
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
||||||
dimensions.sumOf { it.values.size } >= 2 &&
|
dimensions.sumOf { it.values.size } >= 2 &&
|
||||||
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||||
|
// #331 fallback, evaluated only after every stronger branch failed.
|
||||||
|
// Required: address entry + payment entry + one enabled quantity
|
||||||
|
// input. Auxiliary (at least one): grouped options, selection
|
||||||
|
// summary, close control, +/- controls or the lower submit action.
|
||||||
|
requiredEvidenceSameContainer && (
|
||||||
|
dimensions.any { it.values.isNotEmpty() } || hasSelectionSummary || hasClose ||
|
||||||
|
hasQuantityAdjustControls || hasOrderSubmitAction
|
||||||
|
) -> SpecPanelType.REQUIRED_EVIDENCE
|
||||||
else -> SpecPanelType.UNKNOWN
|
else -> SpecPanelType.UNKNOWN
|
||||||
}
|
}
|
||||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||||
@@ -439,6 +563,12 @@ object PddScreenParser {
|
|||||||
purchaseContextMatched = continuedPurchasePanel,
|
purchaseContextMatched = continuedPurchasePanel,
|
||||||
hasQuantityControls = hasQuantityControls,
|
hasQuantityControls = hasQuantityControls,
|
||||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||||
|
hasAddressEntry = hasAddressEntry,
|
||||||
|
hasPaymentEntry = hasPaymentEntry,
|
||||||
|
hasQuantityInput = hasQuantityInput,
|
||||||
|
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||||
|
quantityInputCount = quantityInputs.size,
|
||||||
|
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||||
@@ -453,6 +583,93 @@ object PddScreenParser {
|
|||||||
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outermost labelled clickable ancestor inside the value region whose label
|
||||||
|
* prefixes every candidate nested in it. Rows that contain several distinct
|
||||||
|
* options never qualify because their members do not share its label.
|
||||||
|
*/
|
||||||
|
private fun optionBlock(
|
||||||
|
candidate: SnapshotNode,
|
||||||
|
candidates: List<SnapshotNode>,
|
||||||
|
visibleByPath: Map<String, SnapshotNode>,
|
||||||
|
sourceByPath: Map<String, SnapshotNode>,
|
||||||
|
inValueRegion: (SnapshotNode) -> Boolean,
|
||||||
|
): SnapshotNode? {
|
||||||
|
fun compact(value: String) = value.replace(Regex("\\s+"), "")
|
||||||
|
var block: SnapshotNode? = null
|
||||||
|
var parentPath = candidate.parentPath
|
||||||
|
while (parentPath != null) {
|
||||||
|
val raw = sourceByPath[parentPath] ?: break
|
||||||
|
val resolved = visibleByPath[parentPath]
|
||||||
|
// Only a block with its own label names the option; a blank row
|
||||||
|
// container resolved from its first child never merges options.
|
||||||
|
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
|
||||||
|
val outer = compact(resolved.label)
|
||||||
|
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||||
|
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break
|
||||||
|
block = resolved
|
||||||
|
}
|
||||||
|
parentPath = raw.parentPath
|
||||||
|
}
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||||
|
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
|
||||||
|
var current: SnapshotNode? = node
|
||||||
|
var depth = 0
|
||||||
|
while (current != null && depth <= ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH) {
|
||||||
|
if (current.clickable) {
|
||||||
|
val plausible = current.visible && current.enabled && current.bounds.width > 0 && current.bounds.height > 0 &&
|
||||||
|
(screenHeight <= 0 || current.bounds.height * 100 <= screenHeight * ENTRY_ROW_MAX_HEIGHT_PERCENT)
|
||||||
|
return current.takeIf { plausible }
|
||||||
|
}
|
||||||
|
current = current.parentPath?.let(sourceByPath::get)
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address rows: a masked phone inside a nearby clickable row. The address text is never read out. */
|
||||||
|
private fun addressEntryRows(source: List<SnapshotNode>, screenHeight: Int): List<SnapshotNode> {
|
||||||
|
val byPath = source.associateBy(SnapshotNode::path)
|
||||||
|
return source.filter { node ->
|
||||||
|
node.visible && node.bounds.width > 0 && node.bounds.height > 0 && MASKED_PHONE_PATTERN.containsMatchIn(node.label)
|
||||||
|
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payment-method rows via the configurable payment aliases inside a nearby clickable row. */
|
||||||
|
private fun paymentEntryRows(source: List<SnapshotNode>, screenHeight: Int, aliases: List<String>): List<SnapshotNode> {
|
||||||
|
val byPath = source.associateBy(SnapshotNode::path)
|
||||||
|
return source.filter { node ->
|
||||||
|
node.visible && node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||||
|
node.label.replace(Regex("\\s+"), "").let { compact -> aliases.any(compact::contains) }
|
||||||
|
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #331: true when the lowest common ancestor of [nodes] is a real panel
|
||||||
|
* container: it is not a window root (a node without parent) and, like
|
||||||
|
* boundedScrollables, it does not cover the whole screen. Items taken from
|
||||||
|
* separate dialogs/pages only meet at the root or a full-screen wrapper.
|
||||||
|
*/
|
||||||
|
private fun sharesBoundedPanelContainer(nodes: List<SnapshotNode>, sourceByPath: Map<String, SnapshotNode>, screenArea: Long): Boolean {
|
||||||
|
fun chain(node: SnapshotNode): List<SnapshotNode> {
|
||||||
|
val result = mutableListOf(node)
|
||||||
|
var current = node.parentPath?.let(sourceByPath::get)
|
||||||
|
while (current != null) {
|
||||||
|
result += current
|
||||||
|
current = current.parentPath?.let(sourceByPath::get)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
val others = nodes.drop(1).map { node -> chain(node).map(SnapshotNode::path).toSet() }
|
||||||
|
val common = chain(nodes.first()).firstOrNull { candidate -> others.all { candidate.path in it } } ?: return false
|
||||||
|
val parent = common.parentPath?.let(sourceByPath::get) ?: return false
|
||||||
|
if (parent.path == common.path || common.bounds.width <= 0 || common.bounds.height <= 0) return false
|
||||||
|
return screenArea == 0L || common.bounds.width.toLong() * common.bounds.height < screenArea
|
||||||
|
}
|
||||||
|
|
||||||
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
|
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
|
||||||
val candidatePrefix = "${candidate.path}/"
|
val candidatePrefix = "${candidate.path}/"
|
||||||
return source.none { node ->
|
return source.none { node ->
|
||||||
@@ -1572,6 +1789,8 @@ class PddProductDetailCollector(
|
|||||||
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
|
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
|
||||||
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
|
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
|
||||||
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
|
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
|
||||||
|
"addr=${it.hasAddressEntry.toInt()};pay=${it.hasPaymentEntry.toInt()};qin=${it.quantityInputCount};" +
|
||||||
|
"adj=${it.hasQuantityAdjustControls.toInt()};close=${it.hasCloseControl.toInt()};" +
|
||||||
"changed=${screenChanged.toInt()};" +
|
"changed=${screenChanged.toInt()};" +
|
||||||
"recovery=${when {
|
"recovery=${when {
|
||||||
recoverySucceeded -> "success"
|
recoverySucceeded -> "success"
|
||||||
|
|||||||
+94
-13
@@ -40,6 +40,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
|
|||||||
class PurchaseLiveAutomation(
|
class PurchaseLiveAutomation(
|
||||||
private val driver: PurchaseUiDriver,
|
private val driver: PurchaseUiDriver,
|
||||||
private val pause: (Long) -> Unit = Thread::sleep,
|
private val pause: (Long) -> Unit = Thread::sleep,
|
||||||
|
/** Boolean/count-only diagnostics; never receives address text. */
|
||||||
|
private val panelDiagnostic: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
private var submitAttempted = false
|
private var submitAttempted = false
|
||||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||||
@@ -523,7 +525,7 @@ class PurchaseLiveAutomation(
|
|||||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||||
click(save.single(), "保存地址")
|
click(save.single(), "保存地址")
|
||||||
val savedEvidence = waitForStableAddressEditorExit()
|
val savedEvidence = waitForStableAddressEditorExit(expected, suffix)
|
||||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||||
@@ -540,11 +542,7 @@ class PurchaseLiveAutomation(
|
|||||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||||
return screen.specPanelType in setOf(
|
return screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES
|
||||||
SpecPanelType.NORMAL_SCROLLABLE,
|
|
||||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
|
||||||
SpecPanelType.ORDER_CONFIRMATION,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreFinalEvidenceInCurrentPanel(
|
private fun restoreFinalEvidenceInCurrentPanel(
|
||||||
@@ -598,14 +596,14 @@ class PurchaseLiveAutomation(
|
|||||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||||
|
|
||||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
private fun waitForStableAddressEditorExit(expected: String, suffix: String): UiSnapshot {
|
||||||
var consecutiveExits = 0
|
var consecutiveExits = 0
|
||||||
repeat(50) {
|
repeat(50) {
|
||||||
val snapshot = driver.capture()
|
val snapshot = driver.capture()
|
||||||
pageProblem(snapshot)
|
pageProblem(snapshot)
|
||||||
if (snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()) {
|
if (snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()) {
|
||||||
consecutiveExits++
|
consecutiveExits++
|
||||||
if (consecutiveExits >= 2) return snapshot
|
if (consecutiveExits >= 2) return waitForSettledPanelAfterAddressSave(snapshot, expected, suffix)
|
||||||
} else {
|
} else {
|
||||||
consecutiveExits = 0
|
consecutiveExits = 0
|
||||||
}
|
}
|
||||||
@@ -614,6 +612,68 @@ class PurchaseLiveAutomation(
|
|||||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单")
|
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #331: after the editor closes PDD may still be rebuilding the purchase
|
||||||
|
* sheet. Accept immediately when the final evidence or a stronger panel type
|
||||||
|
* is already present. A sheet recognized only through the required-evidence
|
||||||
|
* fallback must also be structurally stable for two consecutive samples. A
|
||||||
|
* stable page without any purchase-sheet marker (quantity input or payment
|
||||||
|
* entry), e.g. the address list, keeps the existing caller path. A partially
|
||||||
|
* loaded sheet is waited for up to [SpecPanelStabilityPolicy.WAIT_BOUND_MS]
|
||||||
|
* without pressing Back and then fails explicitly.
|
||||||
|
*/
|
||||||
|
private fun waitForSettledPanelAfterAddressSave(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
|
||||||
|
var snapshot = initial
|
||||||
|
var previousSignature: String? = null
|
||||||
|
var waitedMs = 0L
|
||||||
|
var samples = 0
|
||||||
|
while (true) {
|
||||||
|
samples++
|
||||||
|
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||||
|
val editorGone = snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()
|
||||||
|
val signature = structureSignature(snapshot)
|
||||||
|
val stable = editorGone && signature == previousSignature
|
||||||
|
val outcome = when {
|
||||||
|
editorGone && hasFinalSavedAddressEvidence(snapshot, expected, suffix) -> "final_evidence"
|
||||||
|
editorGone && screen.specPanelType in STRONG_PURCHASE_PANEL_TYPES -> "recognized"
|
||||||
|
stable && screen.specPanelType == SpecPanelType.REQUIRED_EVIDENCE -> "stable_required"
|
||||||
|
stable && !screen.hasQuantityInput && !screen.hasPaymentEntry -> "non_panel"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (outcome != null) {
|
||||||
|
addressSaveDiagnostic(outcome, waitedMs, samples, screen)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if (waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS) {
|
||||||
|
addressSaveDiagnostic("timeout", waitedMs, samples, screen)
|
||||||
|
fail(
|
||||||
|
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
|
||||||
|
"地址保存后规格面板未完整加载,未按返回键,未创建订单 [waitedMs=$waitedMs;samples=$samples;${screen.requiredEvidenceSummary()}]${screen.requiredEvidenceHintSuffix()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
previousSignature = signature
|
||||||
|
pause(SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS)
|
||||||
|
waitedMs += SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS
|
||||||
|
snapshot = driver.capture()
|
||||||
|
pageProblem(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addressSaveDiagnostic(outcome: String, waitedMs: Long, samples: Int, screen: ParsedPddScreen) {
|
||||||
|
panelDiagnostic(
|
||||||
|
"addressSaveWait;outcome=$outcome;waitedMs=$waitedMs;samples=$samples;backPressed=false;" +
|
||||||
|
"type=${screen.specPanelType};${screen.requiredEvidenceSummary()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory structure only (path, class, bounds, flags); no text is included. */
|
||||||
|
private fun structureSignature(snapshot: UiSnapshot): String = snapshot.nodes
|
||||||
|
.filter { it.visible }
|
||||||
|
.joinToString("|") { node ->
|
||||||
|
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable, node.enabled)
|
||||||
|
.joinToString(":")
|
||||||
|
}
|
||||||
|
|
||||||
private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace)
|
private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace)
|
||||||
|
|
||||||
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
||||||
@@ -716,10 +776,25 @@ class PurchaseLiveAutomation(
|
|||||||
pause(500)
|
pause(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||||
snapshot,
|
val byPath = snapshot.nodes.associateBy { it.path }
|
||||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||||
)
|
// #331: recognition may treat the submit action as auxiliary, but the
|
||||||
|
// final click target stays strict. A zero-size label or a zero-size
|
||||||
|
// clickable container is never a click target.
|
||||||
|
fun clickableAncestorHasArea(node: SnapshotNode): Boolean {
|
||||||
|
var current: SnapshotNode? = node
|
||||||
|
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
|
||||||
|
return current != null && hasArea(current)
|
||||||
|
}
|
||||||
|
return uniqueClickable(
|
||||||
|
snapshot,
|
||||||
|
snapshot.nodes.filter { node ->
|
||||||
|
node.visible && node.enabled && hasArea(node) && clickableAncestorHasArea(node) &&
|
||||||
|
FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||||
@@ -793,7 +868,13 @@ class PurchaseLiveAutomation(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
val MASKED_PHONE = PddScreenParser.MASKED_PHONE_PATTERN
|
||||||
|
val STRONG_PURCHASE_PANEL_TYPES = setOf(
|
||||||
|
SpecPanelType.NORMAL_SCROLLABLE,
|
||||||
|
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||||
|
SpecPanelType.ORDER_CONFIRMATION,
|
||||||
|
)
|
||||||
|
val PURCHASE_CONFIRMATION_PANEL_TYPES = STRONG_PURCHASE_PANEL_TYPES + SpecPanelType.REQUIRED_EVIDENCE
|
||||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||||
|
|||||||
+58
-7
@@ -100,7 +100,7 @@ class PurchaseRehearsalExecutor(
|
|||||||
var addressProof: ShippingAddressProof? = null
|
var addressProof: ShippingAddressProof? = null
|
||||||
var irreversibleStarted = false
|
var irreversibleStarted = false
|
||||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||||
val live = PurchaseLiveAutomation(driver, pause)
|
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||||
for (action in rule.actions) {
|
for (action in rule.actions) {
|
||||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||||
// spec_probe. A later manual retry may start from Agent (or another
|
// spec_probe. A later manual retry may start from Agent (or another
|
||||||
@@ -394,7 +394,7 @@ class PurchaseRehearsalExecutor(
|
|||||||
if (wait.changed) {
|
if (wait.changed) {
|
||||||
return failure(
|
return failure(
|
||||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ class PurchaseRehearsalExecutor(
|
|||||||
if (wait.changed) {
|
if (wait.changed) {
|
||||||
return failure(
|
return failure(
|
||||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return failure(
|
return failure(
|
||||||
@@ -446,17 +446,51 @@ class PurchaseRehearsalExecutor(
|
|||||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||||
var last = currentScreen(input)
|
var last = currentScreen(input)
|
||||||
var changed = false
|
var changed = false
|
||||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
var polls = 0
|
||||||
|
var waitedMs = 0L
|
||||||
|
var requiredEvidenceSeen = false
|
||||||
|
var previousStructure: List<SpecActionNodeSignature>? = null
|
||||||
|
fun waitDiagnostic(outcome: String) {
|
||||||
|
if (requiredEvidenceSeen) {
|
||||||
|
panelDiagnostic(
|
||||||
|
"specPanelWait;outcome=$outcome;waitedMs=$waitedMs;samples=$polls;backPressed=false;" +
|
||||||
|
last.requiredEvidenceSummary() + last.requiredEvidenceHintSuffix(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
last = currentScreen(input)
|
last = currentScreen(input)
|
||||||
panelDiagnostic(panelEvidence(last))
|
panelDiagnostic(panelEvidence(last))
|
||||||
if (last.reviewPageOpen) {
|
if (last.reviewPageOpen) {
|
||||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||||
}
|
}
|
||||||
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
|
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
|
||||||
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
|
val structure = specActionSignature(last)
|
||||||
changed = changed || specActionSignature(last) != beforeSignature
|
// Stronger panel types keep their immediate acceptance. The #331
|
||||||
|
// fallback type additionally needs two identical consecutive
|
||||||
|
// structures so a still-loading sheet is not acted on too early.
|
||||||
|
if (last.specPanelOpen && (last.specPanelType != SpecPanelType.REQUIRED_EVIDENCE || structure == previousStructure)) {
|
||||||
|
waitDiagnostic("opened")
|
||||||
|
return SpecPanelWait(last, true, true)
|
||||||
|
}
|
||||||
|
changed = changed || structure != beforeSignature
|
||||||
|
requiredEvidenceSeen = requiredEvidenceSeen ||
|
||||||
|
last.hasAddressEntry || last.hasPaymentEntry || last.hasQuantityInput
|
||||||
|
previousStructure = structure
|
||||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||||
|
waitedMs += SPEC_SELECTION_POLL_MILLIS
|
||||||
|
polls++
|
||||||
|
// A partially loaded purchase sheet may take longer than the plain
|
||||||
|
// entry verification window; it gets the bounded #331 wait. No Back
|
||||||
|
// is pressed while waiting; the caller fails explicitly on timeout.
|
||||||
|
val limitReached = if (requiredEvidenceSeen) {
|
||||||
|
waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS
|
||||||
|
} else {
|
||||||
|
polls >= SPEC_POST_CLICK_VERIFY_POLLS
|
||||||
|
}
|
||||||
|
if (limitReached) break
|
||||||
}
|
}
|
||||||
|
waitDiagnostic("timeout")
|
||||||
return SpecPanelWait(last, false, changed)
|
return SpecPanelWait(last, false, changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +512,8 @@ class PurchaseRehearsalExecutor(
|
|||||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||||
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
||||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched};" +
|
||||||
|
screen.requiredEvidenceSummary()
|
||||||
|
|
||||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||||
@@ -574,9 +609,11 @@ class PurchaseRehearsalExecutor(
|
|||||||
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||||
verification.failure?.let { return it }
|
verification.failure?.let { return it }
|
||||||
if (verification.confirmed) return null
|
if (verification.confirmed) return null
|
||||||
|
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||||
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
||||||
}
|
}
|
||||||
if (outcome.result == FreshActionResult.SUCCESS) {
|
if (outcome.result == FreshActionResult.SUCCESS) {
|
||||||
|
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||||
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
||||||
}
|
}
|
||||||
val subreason = when (outcome.reason) {
|
val subreason = when (outcome.reason) {
|
||||||
@@ -589,6 +626,19 @@ class PurchaseRehearsalExecutor(
|
|||||||
return failure(SPEC_CLICK_FAILED, subreason)
|
return failure(SPEC_CLICK_FAILED, subreason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332/task 535: parsed option texts and selected flags of one dimension.
|
||||||
|
* Spec values are product attributes, not personal data; each is truncated.
|
||||||
|
*/
|
||||||
|
private fun selectionFailureEvidence(screen: ParsedPddScreen, dimension: String, target: String): String {
|
||||||
|
fun clip(value: String) = value.replace(Regex("[\r\n\t;|]+"), " ").take(SELECTION_DIAGNOSTIC_TEXT_CHARS)
|
||||||
|
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||||
|
return "specSelectionUnconfirmed;dimension=$dimension;target=${clip(target)};valueCount=${values.size};values=" +
|
||||||
|
values.joinToString("|") { value ->
|
||||||
|
"${clip(value.text)}:${(value.node.selected || value.node.checked).toString().first()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class ExactSelectionWait(
|
private data class ExactSelectionWait(
|
||||||
val confirmed: Boolean,
|
val confirmed: Boolean,
|
||||||
val failure: PurchaseExecutionOutcome? = null,
|
val failure: PurchaseExecutionOutcome? = null,
|
||||||
@@ -1175,6 +1225,7 @@ class PurchaseRehearsalExecutor(
|
|||||||
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
||||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||||
|
private const val SELECTION_DIAGNOSTIC_TEXT_CHARS = 40
|
||||||
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
||||||
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
|
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
|
||||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||||
|
|||||||
@@ -781,7 +781,8 @@ class PurchaseRehearsalExecutorTest {
|
|||||||
|
|
||||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
|
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true;" +
|
||||||
|
"addr=0;pay=0;qin=0;adj=0;summary=0;close=0;submit=0;same=0]",
|
||||||
outcome.message,
|
outcome.message,
|
||||||
)
|
)
|
||||||
assertEquals(0, driver.specTapCount)
|
assertEquals(0, driver.specTapCount)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package cn.ilapage.goauto.agent
|
||||||
|
|
||||||
|
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||||
|
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||||
|
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||||
|
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||||
|
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** #332 spec-option dedup (task 535) and the selection-failure diagnostic. */
|
||||||
|
class SpecOptionDedupTest {
|
||||||
|
private fun parse(snapshot: UiSnapshot) =
|
||||||
|
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nested clickable nodes of one option block form one value`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.taskOptionDedupSheet())
|
||||||
|
val colors = screen.dimensions.single { it.key == "color" }.values
|
||||||
|
val sizes = screen.dimensions.single { it.key == "size" }.values
|
||||||
|
|
||||||
|
assertEquals(listOf("兰条纹", "白条纹"), colors.map { it.text })
|
||||||
|
assertTrue(colors.single { it.text == "兰条纹" }.node.selected)
|
||||||
|
assertFalse(colors.single { it.text == "白条纹" }.node.selected)
|
||||||
|
assertEquals(5, sizes.size)
|
||||||
|
assertEquals(listOf("2XL建议130-150斤"), sizes.filter { it.node.selected }.map { it.rawText })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unconfirmed selection reports parsed values selected flags and target`() {
|
||||||
|
// The click never changes the page, so the white option stays unselected.
|
||||||
|
val driver = object : PurchaseUiDriver {
|
||||||
|
var opened = false
|
||||||
|
override fun capture(): UiSnapshot =
|
||||||
|
if (opened) SpecPanelFixtures.taskOptionDedupSheet() else SpecPanelFixtures.productDetailPage()
|
||||||
|
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||||
|
if (target.label.startsWith("请选择")) opened = true
|
||||||
|
return FreshActionResult.SUCCESS
|
||||||
|
}
|
||||||
|
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||||
|
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||||
|
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||||
|
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||||
|
override fun backPurchase() = false
|
||||||
|
}
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||||
|
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||||
|
val evidence = diagnostics.last { it.startsWith("specSelectionUnconfirmed;") }
|
||||||
|
assertTrue(evidence, evidence.contains("dimension=color;target=白条纹;valueCount=2;values=兰条纹:t|白条纹:f"))
|
||||||
|
assertFalse(evidence.contains("****"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun input() = PurchaseExecutionInput(
|
||||||
|
taskId = 332,
|
||||||
|
executionMode = "rehearsal",
|
||||||
|
phase = "purchase",
|
||||||
|
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||||
|
goodsId = "719834019024",
|
||||||
|
mappedColor = "白条纹",
|
||||||
|
mappedSize = "",
|
||||||
|
quantity = 1,
|
||||||
|
minUnitPriceCent = 1_000,
|
||||||
|
maxUnitPriceCent = 3_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun rule() = """{
|
||||||
|
"schemaVersion":1,
|
||||||
|
"ruleType":"pddPurchase",
|
||||||
|
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||||
|
"actions":[
|
||||||
|
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||||
|
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package cn.ilapage.goauto.agent
|
||||||
|
|
||||||
|
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||||
|
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||||
|
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitized spec-panel fixtures (#332 option dedup, #331 recognition). The layout mirrors the structure of real
|
||||||
|
* PDD purchase sheets, but every name, phone number and address is fake.
|
||||||
|
*/
|
||||||
|
internal object SpecPanelFixtures {
|
||||||
|
const val PDD = "com.xunmeng.pinduoduo"
|
||||||
|
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||||
|
const val FAKE_PHONE = "139****0000"
|
||||||
|
|
||||||
|
class Tree(private val screenBottom: Int) {
|
||||||
|
val nodes = mutableListOf<SnapshotNode>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
add("r", "", NodeBounds(0, 0, 1080, screenBottom), className = "android.widget.FrameLayout")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(
|
||||||
|
path: String,
|
||||||
|
text: String,
|
||||||
|
bounds: NodeBounds,
|
||||||
|
className: String = "android.widget.TextView",
|
||||||
|
clickable: Boolean = false,
|
||||||
|
scrollable: Boolean = false,
|
||||||
|
selected: Boolean = false,
|
||||||
|
visible: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
val parent = path.substringBeforeLast('/', "").ifEmpty { null }
|
||||||
|
nodes += SnapshotNode(
|
||||||
|
path, parent, text, description, null, className, bounds,
|
||||||
|
clickable, scrollable, selected, false, enabled, visible,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(packageName: String = PDD, activity: String = ACTIVITY) = UiSnapshot(packageName, activity, nodes.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for the purchase sheet shaped like the two unselected real samples. */
|
||||||
|
data class Sheet(
|
||||||
|
val screenBottom: Int = 2216,
|
||||||
|
val address: Boolean = true,
|
||||||
|
val payment: Boolean = true,
|
||||||
|
val quantityInput: Boolean = true,
|
||||||
|
val adjustButtons: Boolean = true,
|
||||||
|
val close: Boolean = true,
|
||||||
|
val summary: Boolean = true,
|
||||||
|
val sizeDimension: Boolean = true,
|
||||||
|
val listScrollable: Boolean = true,
|
||||||
|
/** "visible" = normal submit bar; "hidden" = Samsung-like 31px bar whose [0,0][0,0] label is not visible; "none". */
|
||||||
|
val submit: String = "visible",
|
||||||
|
val offset: Int = 0,
|
||||||
|
val colorLabels: List<String> = listOf("黑色 示例款", "卡其色 示例款"),
|
||||||
|
val sizeLabels: List<String> = listOf("S 建议75-90斤", "M 建议90-105斤", "L 建议105-115斤"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sheet(options: Sheet = Sheet()): UiSnapshot {
|
||||||
|
val o = options.offset
|
||||||
|
val t = Tree(options.screenBottom)
|
||||||
|
t.add("r/sheet", "", NodeBounds(0, 84, 1080, options.screenBottom), "android.widget.LinearLayout", clickable = true)
|
||||||
|
t.add("r/sheet/top", "", NodeBounds(0, 219, 1080, 330), "android.widget.ViewSwitcher", clickable = true)
|
||||||
|
t.add("r/sheet/top/tags", "#示例标签#七天退换", NodeBounds(193, 240, 886, 308))
|
||||||
|
if (options.close) {
|
||||||
|
t.add("r/sheet/close", "", NodeBounds(975, 237, 1050, 312), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||||
|
}
|
||||||
|
t.add("r/sheet/body", "", NodeBounds(0, 330, 1080, options.screenBottom), "android.view.ViewGroup", clickable = true)
|
||||||
|
if (options.address) {
|
||||||
|
// Address row: clickable row five levels above the masked phone text.
|
||||||
|
t.add("r/sheet/body/addr", "", NodeBounds(0, 330 + o, 1080, 484 + o), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 340 + o, 1080, 470 + o), "android.widget.LinearLayout")
|
||||||
|
t.add("r/sheet/body/addr/a/b", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||||
|
t.add("r/sheet/body/addr/a/b/c", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||||
|
t.add("r/sheet/body/addr/a/b/c/tag", "7天无理由退货", NodeBounds(138, 348 + o, 394, 394 + o))
|
||||||
|
t.add("r/sheet/body/addr/a/b/c/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 346 + o, 993, 395 + o))
|
||||||
|
t.add("r/sheet/body/addr/a/detail", "示例区示例街道示例路1号", NodeBounds(132, 408 + o, 937, 457 + o))
|
||||||
|
}
|
||||||
|
t.add("r/sheet/body/image", "", NodeBounds(36, 638, 360, 962), "android.widget.FrameLayout", clickable = true, description = "商品主图")
|
||||||
|
t.add("r/sheet/body/price", "¥19.6", NodeBounds(384, 640, 523, 707))
|
||||||
|
if (options.summary) t.add("r/sheet/body/summary", "请选择: 颜色分类 尺码", NodeBounds(384, 791, 1068, 852))
|
||||||
|
t.add("r/sheet/body/qty", "", NodeBounds(384, 888, 633, 963), "android.widget.LinearLayout")
|
||||||
|
if (options.adjustButtons) {
|
||||||
|
t.add("r/sheet/body/qty/dec", "", NodeBounds(384, 888, 462, 963), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||||
|
t.add("r/sheet/body/qty/inc", "", NodeBounds(555, 888, 633, 963), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||||
|
}
|
||||||
|
if (options.quantityInput) {
|
||||||
|
t.add("r/sheet/body/qty/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||||
|
}
|
||||||
|
val list = "r/sheet/body/list"
|
||||||
|
t.add(list, "", NodeBounds(0, 1136, 1080, 2036), "androidx.recyclerview.widget.RecyclerView", scrollable = options.listScrollable)
|
||||||
|
t.add("$list/color", "", NodeBounds(0, 1136, 1080, 1686), "android.widget.LinearLayout")
|
||||||
|
t.add("$list/color/h", "颜色分类", NodeBounds(36, 1161, 216, 1222))
|
||||||
|
options.colorLabels.forEachIndexed { index, label ->
|
||||||
|
val step = if (options.colorLabels.size > 3) 258 else 346
|
||||||
|
val w = if (options.colorLabels.size > 3) 240 else 316
|
||||||
|
val left = 36 + index * step
|
||||||
|
val block = "$list/color/o$index"
|
||||||
|
t.add(block, "", NodeBounds(left, 1247, left + w, 1650), "android.view.ViewGroup", clickable = true, description = label)
|
||||||
|
t.add("$block/img", "", NodeBounds(left, 1247, left + w, 1563), "android.widget.ImageView", clickable = true, description = label)
|
||||||
|
t.add("$block/big", "", NodeBounds(left, 1247, left + 111, 1358), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||||
|
t.add("$block/l", "", NodeBounds(left, 1563, left + w, 1650), "android.widget.LinearLayout")
|
||||||
|
t.add("$block/l/t", label, NodeBounds(left, 1563, left + w, 1650), clickable = true)
|
||||||
|
}
|
||||||
|
if (options.sizeDimension) {
|
||||||
|
t.add("$list/size", "", NodeBounds(0, 1686, 1080, 2036), "android.widget.LinearLayout")
|
||||||
|
t.add("$list/size/row", "", NodeBounds(36, 1686, 1044, 1767), "android.widget.LinearLayout", clickable = true)
|
||||||
|
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1700, 126, 1753))
|
||||||
|
t.add("$list/size/row/hint", "查看尺码建议", NodeBounds(150, 1702, 402, 1751))
|
||||||
|
options.sizeLabels.forEachIndexed { index, label ->
|
||||||
|
val top = 1779 + (index / 2) * 85
|
||||||
|
val left = 36 + (index % 2) * 420
|
||||||
|
t.add("$list/size/o$index", "", NodeBounds(left, top, left + 364, top + 80), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("$list/size/o$index/t", label, NodeBounds(left, top, left + 364, top + 80), clickable = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.payment) {
|
||||||
|
t.add("r/sheet/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/sheet/pay/l", "", NodeBounds(94, 2057, 930, 2114), "android.widget.LinearLayout")
|
||||||
|
t.add("r/sheet/pay/l/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2057, 930, 2114))
|
||||||
|
}
|
||||||
|
when (options.submit) {
|
||||||
|
"visible" -> {
|
||||||
|
t.add("r/sheet/submit", "", NodeBounds(0, 2135, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||||
|
t.add("r/sheet/submit/l", "", NodeBounds(145, 2135, 934, options.screenBottom), "android.widget.LinearLayout")
|
||||||
|
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(157, 2179, 922, options.screenBottom))
|
||||||
|
}
|
||||||
|
"hidden" -> {
|
||||||
|
t.add("r/sheet/submit", "", NodeBounds(0, options.screenBottom - 31, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||||
|
t.add("r/sheet/submit/l", "", NodeBounds(0, 0, 0, 0), "android.widget.LinearLayout", visible = false)
|
||||||
|
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 0, 0, 0), visible = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal fallback sheet: no bounded scrollable, a single heading, no
|
||||||
|
* summary, no +/- and no readable submit. Only the #331 required-evidence
|
||||||
|
* fallback can recognize it. (Not the goods 8580 shape; see [sample8580ShapedSheet].)
|
||||||
|
*/
|
||||||
|
fun liveShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||||
|
options.copy(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "hidden"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the 13 goods 8580 failures reported in #331:
|
||||||
|
* `type=UNKNOWN;scrollables=1;headings=2;options=9;summary=false;quantity=true;orderAction=false;pageEvidenceMatched=true`.
|
||||||
|
* One bounded scrollable, two headings, nine options, +/- and input present,
|
||||||
|
* but no selection summary and no readable submit action.
|
||||||
|
*/
|
||||||
|
fun sample8580ShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||||
|
options.copy(
|
||||||
|
summary = false,
|
||||||
|
submit = "hidden",
|
||||||
|
colorLabels = listOf("黑色 示例款", "卡其色 示例款", "白色 示例款", "灰色 示例款"),
|
||||||
|
sizeLabels = listOf("S 示例", "M 示例", "L 示例", "XL 示例", "2XL 示例"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #331 counter-example: a masked-phone row inside an address dialog, a
|
||||||
|
* payment row inside a separate payment dialog and a quantity input in a
|
||||||
|
* third place, together with a parsed option group. Every item exists but
|
||||||
|
* they do not share one panel container.
|
||||||
|
*/
|
||||||
|
fun scatteredRequiredItemsPage(fullScreenWrapper: Boolean = false): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
val root = if (fullScreenWrapper) {
|
||||||
|
t.add("r/content", "", NodeBounds(0, 0, 1080, 2216), "android.widget.FrameLayout")
|
||||||
|
"r/content"
|
||||||
|
} else "r"
|
||||||
|
t.add("$root/addrDialog", "", NodeBounds(60, 200, 1020, 700), "android.widget.FrameLayout")
|
||||||
|
t.add("$root/addrDialog/title", "选择收货地址", NodeBounds(360, 220, 720, 290))
|
||||||
|
t.add("$root/addrDialog/row", "", NodeBounds(60, 320, 1020, 470), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("$root/addrDialog/row/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(100, 340, 980, 400))
|
||||||
|
t.add("$root/payDialog", "", NodeBounds(60, 800, 1020, 1200), "android.widget.FrameLayout")
|
||||||
|
t.add("$root/payDialog/row", "", NodeBounds(60, 900, 1020, 1010), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("$root/payDialog/row/t", "微信支付", NodeBounds(120, 920, 400, 990))
|
||||||
|
t.add("$root/other", "", NodeBounds(0, 1250, 1080, 2216), "android.widget.LinearLayout")
|
||||||
|
t.add("$root/other/close", "", NodeBounds(975, 1260, 1050, 1335), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||||
|
t.add("$root/other/input", "1", NodeBounds(468, 1350, 549, 1425), "android.widget.EditText", clickable = true)
|
||||||
|
t.add("$root/other/h", "颜色分类", NodeBounds(36, 1500, 216, 1560))
|
||||||
|
t.add("$root/other/o0", "黑色 示例款", NodeBounds(36, 1600, 352, 1700), clickable = true)
|
||||||
|
t.add("$root/other/o1", "白色 示例款", NodeBounds(382, 1600, 698, 1700), clickable = true)
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Task 535 layout: the selected option block exposes an outer block, an image and an inner text with a badge. */
|
||||||
|
fun taskOptionDedupSheet(): UiSnapshot {
|
||||||
|
val t = Tree(2328)
|
||||||
|
t.add("r/sheet", "", NodeBounds(0, 120, 1080, 2328), "android.widget.LinearLayout", clickable = true)
|
||||||
|
t.add("r/sheet/close", "", NodeBounds(975, 273, 1050, 348), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||||
|
t.add("r/sheet/body", "", NodeBounds(0, 366, 1080, 2328), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/sheet/body/addr", "", NodeBounds(0, 366, 1080, 520), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 376, 1080, 510), "android.widget.LinearLayout")
|
||||||
|
t.add("r/sheet/body/addr/a/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 382, 993, 431))
|
||||||
|
t.add("r/sheet/body/addr/a/detail", "示例区示例路2号", NodeBounds(132, 444, 993, 493))
|
||||||
|
t.add("r/sheet/body/price", "快抢光 ¥23.99", NodeBounds(396, 568, 722, 635))
|
||||||
|
t.add("r/sheet/body/selected", "#快抢光#兰条纹 2XL建议130-150斤", NodeBounds(396, 719, 1053, 781))
|
||||||
|
t.add("r/sheet/body/qty", "", NodeBounds(396, 817, 614, 895), "android.widget.LinearLayout")
|
||||||
|
t.add("r/sheet/body/qty/dec", "", NodeBounds(396, 817, 462, 895), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||||
|
t.add("r/sheet/body/qty/input", "1", NodeBounds(463, 817, 547, 895), "android.widget.EditText", clickable = true)
|
||||||
|
t.add("r/sheet/body/qty/inc", "", NodeBounds(548, 817, 614, 895), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||||
|
val list = "r/sheet/body/list"
|
||||||
|
t.add(list, "", NodeBounds(0, 933, 1080, 2079), "androidx.recyclerview.widget.RecyclerView", scrollable = true)
|
||||||
|
t.add("$list/color", "", NodeBounds(0, 933, 1080, 1483), "android.widget.LinearLayout")
|
||||||
|
t.add("$list/color/h", "颜色分类", NodeBounds(36, 958, 216, 1019))
|
||||||
|
val blue = "$list/color/o0"
|
||||||
|
t.add(blue, "", NodeBounds(36, 1044, 352, 1447), "android.view.ViewGroup", clickable = true, selected = true, description = " 兰条纹")
|
||||||
|
t.add("$blue/img", "", NodeBounds(36, 1044, 352, 1360), "android.widget.ImageView", clickable = true, selected = true, description = " 兰条纹")
|
||||||
|
t.add("$blue/big", "", NodeBounds(36, 1044, 147, 1155), "android.widget.ImageView", clickable = true, selected = true, description = "打开大图")
|
||||||
|
t.add("$blue/l", "", NodeBounds(36, 1339, 352, 1447), "android.widget.LinearLayout", selected = true)
|
||||||
|
t.add("$blue/l/t", " 兰条纹\n 零差评", NodeBounds(36, 1339, 352, 1447), clickable = true, selected = true)
|
||||||
|
val white = "$list/color/o1"
|
||||||
|
t.add(white, "", NodeBounds(382, 1044, 698, 1447), "android.view.ViewGroup", clickable = true, description = "白条纹")
|
||||||
|
t.add("$white/img", "", NodeBounds(382, 1044, 698, 1360), "android.widget.ImageView", clickable = true, description = "白条纹")
|
||||||
|
t.add("$white/big", "", NodeBounds(382, 1044, 493, 1155), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||||
|
t.add("$white/badge", "快要抢光", NodeBounds(521, 1044, 698, 1093))
|
||||||
|
t.add("$white/l", "", NodeBounds(382, 1339, 698, 1447), "android.widget.LinearLayout")
|
||||||
|
t.add("$white/l/t", "白条纹", NodeBounds(382, 1339, 698, 1447), clickable = true)
|
||||||
|
t.add("$list/size", "", NodeBounds(0, 1483, 1080, 1938), "android.widget.LinearLayout")
|
||||||
|
t.add("$list/size/row", "", NodeBounds(36, 1483, 1044, 1564), "android.widget.LinearLayout", clickable = true)
|
||||||
|
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1497, 126, 1550))
|
||||||
|
listOf(
|
||||||
|
Triple("L建议100-115斤", NodeBounds(36, 1576, 371, 1661), false),
|
||||||
|
Triple("XL建议115-130斤", NodeBounds(401, 1576, 762, 1661), false),
|
||||||
|
Triple("2XL建议130-150斤", NodeBounds(36, 1691, 430, 1776), true),
|
||||||
|
Triple("3XL建议150-170斤", NodeBounds(460, 1691, 853, 1776), false),
|
||||||
|
Triple("4XL建议170-190斤", NodeBounds(36, 1806, 431, 1891), false),
|
||||||
|
).forEachIndexed { index, (label, bounds, selected) ->
|
||||||
|
t.add("$list/size/o$index", "", bounds, "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("$list/size/o$index/t", label, bounds, clickable = true, selected = selected)
|
||||||
|
}
|
||||||
|
t.add("r/sheet/pay", "", NodeBounds(0, 2079, 1080, 2178), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/sheet/pay/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2100, 930, 2157))
|
||||||
|
t.add("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), "android.widget.FrameLayout", clickable = true)
|
||||||
|
t.add("r/sheet/submit/t", "提交订单 ¥23.99", NodeBounds(365, 2225, 715, 2284))
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun productDetailPage(): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
t.add("r/pager", "示例商品标题连衣裙夏季新款示例", NodeBounds(0, 84, 1080, 1100), "androidx.viewpager.widget.ViewPager")
|
||||||
|
t.add("r/price", "¥19.6", NodeBounds(36, 1120, 300, 1200))
|
||||||
|
t.add("r/sales", "已拼1万+件", NodeBounds(700, 1120, 1044, 1200))
|
||||||
|
t.add("r/deliver", "配送至 示例省示例市", NodeBounds(36, 1300, 1044, 1360))
|
||||||
|
t.add("r/spec", "请选择 颜色分类 尺码", NodeBounds(36, 1400, 1044, 1480), clickable = true)
|
||||||
|
t.add("r/review", "商品评价(2000+)", NodeBounds(36, 1500, 1044, 1580), clickable = true)
|
||||||
|
t.add("r/single", "单独购买", NodeBounds(420, 2080, 720, 2200), clickable = true)
|
||||||
|
t.add("r/group", "发起拼单", NodeBounds(740, 2080, 1060, 2200), clickable = true)
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reviewPage(): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
t.add("r/title", "商品评价", NodeBounds(400, 120, 680, 200))
|
||||||
|
t.add("r/all", "全部", NodeBounds(36, 260, 150, 320), clickable = true)
|
||||||
|
t.add("r/media", "有图/视频", NodeBounds(170, 260, 360, 320), clickable = true)
|
||||||
|
t.add("r/body", "质量不错,微信支付很方便", NodeBounds(36, 400, 1044, 500))
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addressListPage(): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
t.add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||||
|
listOf(0, 1).forEach { index ->
|
||||||
|
val top = 300 + index * 260
|
||||||
|
t.add("r/card$index", "", NodeBounds(0, top, 1080, top + 240), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/card$index/phone", "测试 $FAKE_PHONE", NodeBounds(36, top + 20, 700, top + 80))
|
||||||
|
t.add("r/card$index/detail", "示例省示例市示例路${index + 1}号", NodeBounds(36, top + 100, 900, top + 160))
|
||||||
|
t.add("r/card$index/edit", "修改", NodeBounds(950, top + 20, 1044, top + 80), clickable = true)
|
||||||
|
}
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addressEditPage(): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
t.add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||||
|
t.add("r/name", "测试收货人", NodeBounds(180, 190, 900, 270), "android.widget.EditText", clickable = true)
|
||||||
|
t.add("r/phone", "13900000000", NodeBounds(180, 290, 900, 370), "android.widget.EditText", clickable = true)
|
||||||
|
t.add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||||
|
t.add("r/detail", "示例路1号", NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||||
|
t.add("r/save", "保存", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun paymentMethodDialog(): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
t.add("r/title", "选择支付方式", NodeBounds(360, 1200, 720, 1280))
|
||||||
|
t.add("r/wechat", "", NodeBounds(0, 1300, 1080, 1420), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/wechat/t", "微信支付", NodeBounds(120, 1330, 400, 1390))
|
||||||
|
t.add("r/later", "", NodeBounds(0, 1440, 1080, 1560), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/later/t", "先用后付", NodeBounds(120, 1470, 400, 1530))
|
||||||
|
t.add("r/ok", "确定", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postSubmitPaymentPage(): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
t.add("r/amount", "¥19.60", NodeBounds(360, 300, 720, 400))
|
||||||
|
t.add("r/wechat", "", NodeBounds(0, 600, 1080, 720), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/wechat/t", "微信支付", NodeBounds(120, 630, 400, 690))
|
||||||
|
t.add("r/pay", "立即支付", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||||
|
return t.snapshot(activity = "com.xunmeng.pinduoduo.app_pay.core.PayActivity")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun quantityOnlyPage(): UiSnapshot {
|
||||||
|
val t = Tree(2216)
|
||||||
|
t.add("r/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||||
|
return t.snapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
package cn.ilapage.goauto.agent
|
||||||
|
|
||||||
|
import cn.ilapage.goauto.agent.SpecPanelFixtures.Sheet
|
||||||
|
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||||
|
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||||
|
import cn.ilapage.goauto.agent.automation.ParsedPddScreen
|
||||||
|
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseLiveException
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||||
|
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||||
|
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||||
|
import cn.ilapage.goauto.agent.automation.SpecPanelStabilityPolicy
|
||||||
|
import cn.ilapage.goauto.agent.automation.SpecPanelType
|
||||||
|
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||||
|
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** #331 spec-panel recognition, same-container check, hints, bounded waits and strict submit targets. */
|
||||||
|
class SpecPanelRecognitionTest {
|
||||||
|
private fun parse(snapshot: UiSnapshot) =
|
||||||
|
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||||
|
|
||||||
|
// --- Recognition -------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both sanitized unselected sheet samples are recognized as spec panels`() {
|
||||||
|
val first = parse(SpecPanelFixtures.sheet())
|
||||||
|
val samsung = parse(SpecPanelFixtures.sheet(Sheet(screenBottom = 2020, submit = "hidden")))
|
||||||
|
|
||||||
|
listOf(first, samsung).forEach { screen ->
|
||||||
|
assertTrue(screen.specPanelOpen)
|
||||||
|
assertTrue(screen.hasAddressEntry)
|
||||||
|
assertTrue(screen.hasPaymentEntry)
|
||||||
|
assertTrue(screen.hasQuantityInput)
|
||||||
|
}
|
||||||
|
// The zero-size submit label is not visible, so the submit action is absent.
|
||||||
|
assertFalse(samsung.hasOrderSubmitAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `live shaped samples are recognized only through the required evidence fallback`() {
|
||||||
|
listOf(2216, 2020).forEach { bottom ->
|
||||||
|
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = bottom)))
|
||||||
|
|
||||||
|
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||||
|
assertTrue(screen.specPanelOpen)
|
||||||
|
assertEquals(0, screen.panelScrollableCount)
|
||||||
|
assertFalse(screen.hasSelectionSummary)
|
||||||
|
assertFalse(screen.hasQuantityControls)
|
||||||
|
assertFalse(screen.hasQuantityAdjustControls)
|
||||||
|
assertFalse(screen.hasOrderSubmitAction)
|
||||||
|
assertEquals(listOf("黑色 示例款", "卡其色 示例款"), screen.dimensions.single().values.map { it.text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zero size submit label sample is still recognized because submit is auxiliary`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2020)))
|
||||||
|
|
||||||
|
assertFalse(screen.hasOrderSubmitAction)
|
||||||
|
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing address entry is not recognized`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(address = false)))
|
||||||
|
assertFalse(screen.hasAddressEntry)
|
||||||
|
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing payment entry is not recognized`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(payment = false)))
|
||||||
|
assertFalse(screen.hasPaymentEntry)
|
||||||
|
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing quantity input is not recognized`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(quantityInput = false)))
|
||||||
|
assertFalse(screen.hasQuantityInput)
|
||||||
|
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `required items without any auxiliary evidence are not recognized`() {
|
||||||
|
val t = SpecPanelFixtures.Tree(2216)
|
||||||
|
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2216), "android.widget.LinearLayout")
|
||||||
|
t.add("r/panel/addr", "", NodeBounds(0, 330, 1080, 484), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||||
|
t.add("r/panel/qty", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||||
|
t.add("r/panel/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||||
|
t.add("r/panel/pay/t", "使用微信支付", NodeBounds(112, 2057, 930, 2114))
|
||||||
|
|
||||||
|
val screen = parse(t.snapshot())
|
||||||
|
|
||||||
|
assertTrue(screen.hasRequiredPanelEvidence)
|
||||||
|
assertTrue(screen.requiredEvidenceSameContainer)
|
||||||
|
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quantity input alone is not recognized`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.quantityOnlyPage())
|
||||||
|
assertTrue(screen.hasQuantityInput)
|
||||||
|
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||||
|
assertFalse(screen.specPanelOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non panel pages are never recognized as spec panels`() {
|
||||||
|
mapOf(
|
||||||
|
"product" to SpecPanelFixtures.productDetailPage(),
|
||||||
|
"review" to SpecPanelFixtures.reviewPage(),
|
||||||
|
"addressList" to SpecPanelFixtures.addressListPage(),
|
||||||
|
"addressEdit" to SpecPanelFixtures.addressEditPage(),
|
||||||
|
"paymentDialog" to SpecPanelFixtures.paymentMethodDialog(),
|
||||||
|
"paymentPage" to SpecPanelFixtures.postSubmitPaymentPage(),
|
||||||
|
).forEach { (name, snapshot) ->
|
||||||
|
val screen = parse(snapshot)
|
||||||
|
assertEquals(name, SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||||
|
assertFalse(name, screen.specPanelOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `whole sheet clickable containers do not count as an address row`() {
|
||||||
|
val t = SpecPanelFixtures.Tree(2216)
|
||||||
|
t.add("r/sheet", "", NodeBounds(0, 84, 1080, 2216), "android.widget.LinearLayout", clickable = true)
|
||||||
|
t.add("r/sheet/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||||
|
assertFalse(parse(t.snapshot()).hasAddressEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single dimension sheet without adjust buttons is recognized`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.sheet(Sheet(sizeDimension = false, adjustButtons = false, summary = false, listScrollable = false, submit = "none")))
|
||||||
|
|
||||||
|
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||||
|
assertEquals(listOf("color"), screen.dimensions.map { it.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `required evidence diagnostics hold booleans and counts only`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.liveShapedSheet())
|
||||||
|
val summary = screen.requiredEvidenceSummary()
|
||||||
|
|
||||||
|
assertEquals("addr=1;pay=1;qin=1;adj=0;summary=0;close=1;submit=0;same=1", summary)
|
||||||
|
assertFalse(summary.contains("****"))
|
||||||
|
assertFalse(summary.contains("示例"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `goods 8580 shaped sheet is recognized through the required evidence fallback`() {
|
||||||
|
val screen = parse(SpecPanelFixtures.sample8580ShapedSheet())
|
||||||
|
|
||||||
|
// Shape of the 13 live failures: scrollables=1;headings=2;options=9;
|
||||||
|
// summary=false;quantity=true;orderAction=false (was UNKNOWN before #331).
|
||||||
|
assertEquals(1, screen.panelScrollableCount)
|
||||||
|
assertEquals(2, screen.panelHeadingCount)
|
||||||
|
assertEquals(9, screen.panelOptionCount)
|
||||||
|
assertFalse(screen.hasSelectionSummary)
|
||||||
|
assertTrue(screen.hasQuantityControls)
|
||||||
|
assertFalse(screen.hasOrderSubmitAction)
|
||||||
|
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||||
|
assertTrue(screen.requiredEvidenceSameContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Same panel container --------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `required items inside one panel container are recognized`() {
|
||||||
|
listOf(SpecPanelFixtures.liveShapedSheet(), SpecPanelFixtures.sample8580ShapedSheet()).forEach { snapshot ->
|
||||||
|
val screen = parse(snapshot)
|
||||||
|
assertTrue(screen.requiredEvidenceSameContainer)
|
||||||
|
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `address dialog payment dialog and a separate quantity input are not a spec panel`() {
|
||||||
|
listOf(false, true).forEach { wrapper ->
|
||||||
|
val screen = parse(SpecPanelFixtures.scatteredRequiredItemsPage(fullScreenWrapper = wrapper))
|
||||||
|
|
||||||
|
// Every required item exists somewhere on screen, plus auxiliary
|
||||||
|
// options and a close control, but not inside one panel container.
|
||||||
|
assertTrue(screen.hasAddressEntry)
|
||||||
|
assertTrue(screen.hasPaymentEntry)
|
||||||
|
assertTrue(screen.hasQuantityInput)
|
||||||
|
assertTrue(screen.dimensions.any { it.values.isNotEmpty() })
|
||||||
|
assertFalse(screen.requiredEvidenceSameContainer)
|
||||||
|
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||||
|
assertFalse(screen.specPanelOpen)
|
||||||
|
assertEquals(listOf(ParsedPddScreen.NOT_SAME_CONTAINER_HINT), screen.requiredEvidenceHints())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Purchaser hints for missing required items --------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing required items produce purchaser hints`() {
|
||||||
|
val noPay = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false)))
|
||||||
|
val noAddr = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(address = false)))
|
||||||
|
val noQty = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(quantityInput = false)))
|
||||||
|
|
||||||
|
assertEquals(listOf("未找到支付入口:请确认 PDD 默认支付方式为微信支付"), noPay.requiredEvidenceHints())
|
||||||
|
assertEquals(listOf("未找到收货地址入口:请确认 PDD 已设置默认收货地址"), noAddr.requiredEvidenceHints())
|
||||||
|
assertEquals(listOf("未找到购买数量输入框"), noQty.requiredEvidenceHints())
|
||||||
|
assertTrue(parse(SpecPanelFixtures.sample8580ShapedSheet()).requiredEvidenceHints().isEmpty())
|
||||||
|
// Unrelated pages without any required item or option do not get environment hints.
|
||||||
|
assertTrue(parse(SpecPanelFixtures.reviewPage()).requiredEvidenceHints().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spec panel evidence failure message carries the missing payment hint`() {
|
||||||
|
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false))))
|
||||||
|
|
||||||
|
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||||
|
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||||
|
assertTrue(outcome.message.orEmpty().contains("未找到支付入口:请确认 PDD 默认支付方式为微信支付"))
|
||||||
|
assertFalse(outcome.message.orEmpty().contains("****"))
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spec panel evidence failure message carries the missing address and quantity hints`() {
|
||||||
|
mapOf(
|
||||||
|
Sheet(address = false) to "未找到收货地址入口:请确认 PDD 已设置默认收货地址",
|
||||||
|
Sheet(quantityInput = false) to "未找到购买数量输入框",
|
||||||
|
).forEach { (options, hint) ->
|
||||||
|
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(options)))
|
||||||
|
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||||
|
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||||
|
assertTrue(outcome.message.orEmpty(), outcome.message.orEmpty().contains(hint))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `address save timeout message carries the missing address hint`() {
|
||||||
|
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||||
|
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||||
|
|
||||||
|
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg35") }
|
||||||
|
.exceptionOrNull() as PurchaseLiveException
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||||
|
assertTrue(error.message.orEmpty().contains("未找到收货地址入口:请确认 PDD 已设置默认收货地址"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bounded wait after the first spec-entry click -----------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loading sheet is accepted after required evidence becomes complete and stable without back`() {
|
||||||
|
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||||
|
val ready = SpecPanelFixtures.liveShapedSheet()
|
||||||
|
val driver = SpecEntryDriver(listOf(loading, loading, loading, ready, ready))
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
val steps = mutableListOf<String>()
|
||||||
|
|
||||||
|
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||||
|
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||||
|
|
||||||
|
assertTrue(steps.contains("selectSpec"))
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
assertTrue(diagnostics.any { it.startsWith("specPanelWait;outcome=opened;") && it.contains("backPressed=false") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `always incomplete sheet fails explicitly at the wait bound without back`() {
|
||||||
|
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||||
|
val driver = SpecEntryDriver(listOf(loading))
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||||
|
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
val timeout = diagnostics.single { it.startsWith("specPanelWait;outcome=timeout;") }
|
||||||
|
assertTrue(timeout.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fallback sheet whose structure keeps changing is never accepted early`() {
|
||||||
|
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||||
|
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||||
|
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, parse(second).specPanelType)
|
||||||
|
val driver = SpecEntryDriver(listOf(first, second), cycle = true)
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||||
|
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
assertFalse(diagnostics.any { it.startsWith("specPanelWait;outcome=opened") })
|
||||||
|
assertTrue(diagnostics.any { it.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stronger panel type is still accepted immediately`() {
|
||||||
|
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sheet()))
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
val steps = mutableListOf<String>()
|
||||||
|
|
||||||
|
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||||
|
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||||
|
|
||||||
|
assertTrue(steps.contains("selectSpec"))
|
||||||
|
assertFalse(diagnostics.any { it.startsWith("specPanelWait;") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bounded wait after the address save ---------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `address save waits for the loading sheet without pressing back`() {
|
||||||
|
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||||
|
val driver = AddressSaveDriver(listOf(loading, loading, loading)) { expected -> readySheet(expected) }
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
val proof = PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg31")
|
||||||
|
|
||||||
|
assertEquals("_cg31", proof.suffix)
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
val done = diagnostics.single { it.startsWith("addressSaveWait;") }
|
||||||
|
assertTrue(done.contains("outcome=final_evidence"))
|
||||||
|
assertTrue(done.contains("waitedMs=${2 * SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||||
|
assertTrue(done.contains("backPressed=false"))
|
||||||
|
assertFalse(diagnostics.any { it.contains("_cg31") || it.contains("示例") || it.contains("****") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `address save with a sheet that never completes fails at the bound without back`() {
|
||||||
|
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||||
|
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg32") }
|
||||||
|
.exceptionOrNull() as PurchaseLiveException
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
assertTrue(diagnostics.single().contains("outcome=timeout;waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||||
|
assertFalse(error.message.orEmpty().contains("示例"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `address save with an unstable fallback sheet does not decide early`() {
|
||||||
|
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||||
|
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||||
|
val driver = AddressSaveDriver(listOf(first, second), cycle = true) { first }
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg33") }
|
||||||
|
.exceptionOrNull() as PurchaseLiveException
|
||||||
|
|
||||||
|
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
assertTrue(diagnostics.single().contains("outcome=timeout"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `address save with a stable fallback sheet is accepted as purchase panel without back`() {
|
||||||
|
val sheet = SpecPanelFixtures.liveShapedSheet()
|
||||||
|
val driver = AddressSaveDriver(listOf(sheet)) { sheet }
|
||||||
|
val diagnostics = mutableListOf<String>()
|
||||||
|
|
||||||
|
// The stable sheet has no saved suffix and no scroll region, so the
|
||||||
|
// existing in-panel restore path fails closed; it must not press Back.
|
||||||
|
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg34") }
|
||||||
|
.exceptionOrNull() as PurchaseLiveException
|
||||||
|
|
||||||
|
assertEquals(0, driver.backs)
|
||||||
|
assertTrue(diagnostics.single().contains("outcome=stable_required;waitedMs=${SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||||
|
assertNotEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Strict final submit click -------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit click fails for zero size hidden unmatched or duplicated buttons`() {
|
||||||
|
fun page(build: SpecPanelFixtures.Tree.() -> Unit) = SpecPanelFixtures.Tree(2216).apply(build).snapshot()
|
||||||
|
val cases = mapOf(
|
||||||
|
"hiddenZeroLabel" to SpecPanelFixtures.sheet(Sheet(submit = "hidden")),
|
||||||
|
"zeroSizeVisibleLabel" to page {
|
||||||
|
add("r/submit", "", NodeBounds(0, 2185, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||||
|
add("r/submit/t", "提交订单", NodeBounds(0, 0, 0, 0))
|
||||||
|
},
|
||||||
|
"zeroSizeContainer" to page {
|
||||||
|
add("r/submit", "", NodeBounds(0, 0, 0, 0), "android.widget.FrameLayout", clickable = true)
|
||||||
|
add("r/submit/t", "提交订单", NodeBounds(157, 2179, 922, 2216))
|
||||||
|
},
|
||||||
|
"labelMismatch" to SpecPanelFixtures.sheet(),
|
||||||
|
"duplicated" to page {
|
||||||
|
add("r/a", "", NodeBounds(0, 2000, 540, 2216), "android.widget.FrameLayout", clickable = true)
|
||||||
|
add("r/a/t", "提交订单", NodeBounds(20, 2050, 520, 2150))
|
||||||
|
add("r/b", "", NodeBounds(540, 2000, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||||
|
add("r/b/t", "提交订单", NodeBounds(560, 2050, 1060, 2150))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cases.forEach { (name, snapshot) ->
|
||||||
|
val driver = StaticDriver(snapshot)
|
||||||
|
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||||
|
|
||||||
|
assertEquals(name, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||||
|
assertTrue(name, driver.clicked.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unique visible submit button is still clickable exactly once`() {
|
||||||
|
val driver = StaticDriver(
|
||||||
|
SpecPanelFixtures.Tree(2216).apply {
|
||||||
|
add("r/submit", "", NodeBounds(0, 2135, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||||
|
add("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210))
|
||||||
|
}.snapshot(),
|
||||||
|
)
|
||||||
|
|
||||||
|
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||||
|
|
||||||
|
assertEquals(listOf("提交订单"), driver.clicked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun readySheet(expected: String): UiSnapshot {
|
||||||
|
val base = SpecPanelFixtures.liveShapedSheet()
|
||||||
|
val extra = listOf(
|
||||||
|
node("r/sheet/body/addr/a/saved", expected, NodeBounds(132, 460, 937, 480), parent = "r/sheet/body/addr/a"),
|
||||||
|
node("r/final", "", NodeBounds(600, 2150, 1060, 2216), clickable = true, parent = "r"),
|
||||||
|
node("r/final/t", "提交订单", NodeBounds(620, 2160, 1040, 2210), parent = "r/final"),
|
||||||
|
)
|
||||||
|
return base.copy(nodes = base.nodes + extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun input() = PurchaseExecutionInput(
|
||||||
|
taskId = 331,
|
||||||
|
executionMode = "rehearsal",
|
||||||
|
phase = "purchase",
|
||||||
|
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||||
|
goodsId = "719834019024",
|
||||||
|
mappedColor = "黑色 示例款",
|
||||||
|
mappedSize = "",
|
||||||
|
quantity = 1,
|
||||||
|
minUnitPriceCent = 1_000,
|
||||||
|
maxUnitPriceCent = 3_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun rule() = """{
|
||||||
|
"schemaVersion":1,
|
||||||
|
"ruleType":"pddPurchase",
|
||||||
|
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||||
|
"actions":[
|
||||||
|
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||||
|
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
private open class BaseDriver : PurchaseUiDriver {
|
||||||
|
var backs = 0
|
||||||
|
val clicked = mutableListOf<String>()
|
||||||
|
override fun capture(): UiSnapshot = UiSnapshot(null, null, emptyList())
|
||||||
|
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||||
|
clicked += target.label
|
||||||
|
return FreshActionResult.SUCCESS
|
||||||
|
}
|
||||||
|
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||||
|
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||||
|
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||||
|
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||||
|
override fun backPurchase(): Boolean {
|
||||||
|
backs++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class StaticDriver(private val snapshot: UiSnapshot) : BaseDriver() {
|
||||||
|
override fun capture() = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Product page first; after the spec-entry click it serves [panels] in order (the last repeats unless [cycle]). */
|
||||||
|
private class SpecEntryDriver(private val panels: List<UiSnapshot>, private val cycle: Boolean = false) : BaseDriver() {
|
||||||
|
private var opened = false
|
||||||
|
var panelCaptures = 0
|
||||||
|
|
||||||
|
override fun capture(): UiSnapshot {
|
||||||
|
if (!opened) return SpecPanelFixtures.productDetailPage()
|
||||||
|
val index = if (cycle) panelCaptures % panels.size else minOf(panelCaptures, panels.lastIndex)
|
||||||
|
panelCaptures++
|
||||||
|
return panels[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||||
|
clicked += target.label
|
||||||
|
if (target.label.startsWith("请选择")) opened = true
|
||||||
|
return FreshActionResult.SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address flow driver: sheet -> address list -> editor -> [afterSave] samples -> [settled]. */
|
||||||
|
private class AddressSaveDriver(
|
||||||
|
private val afterSave: List<UiSnapshot>,
|
||||||
|
private val cycle: Boolean = false,
|
||||||
|
private val settled: (String) -> UiSnapshot,
|
||||||
|
) : BaseDriver() {
|
||||||
|
private var page = "sheet"
|
||||||
|
private var address = "示例路1号-old"
|
||||||
|
private var afterSaveCaptures = 0
|
||||||
|
|
||||||
|
override fun capture(): UiSnapshot = when (page) {
|
||||||
|
"sheet" -> SpecPanelFixtures.sheet()
|
||||||
|
"list" -> SpecPanelFixtures.Tree(2216).apply {
|
||||||
|
add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||||
|
add("r/modify", "", NodeBounds(900, 300, 1044, 380), "android.view.ViewGroup", clickable = true)
|
||||||
|
add("r/modify/t", "修改", NodeBounds(920, 310, 1030, 370))
|
||||||
|
}.snapshot()
|
||||||
|
"edit" -> SpecPanelFixtures.Tree(2216).apply {
|
||||||
|
add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||||
|
add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||||
|
add("r/detail", address, NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||||
|
add("r/save", "", NodeBounds(36, 2080, 1044, 2180), "android.view.ViewGroup", clickable = true)
|
||||||
|
add("r/save/t", "保存", NodeBounds(400, 2100, 700, 2160))
|
||||||
|
}.snapshot()
|
||||||
|
else -> {
|
||||||
|
val index = afterSaveCaptures++
|
||||||
|
when {
|
||||||
|
cycle -> afterSave[index % afterSave.size]
|
||||||
|
index < afterSave.size -> afterSave[index]
|
||||||
|
else -> settled(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||||
|
clicked += target.label
|
||||||
|
when (target.label) {
|
||||||
|
"修改" -> page = "edit"
|
||||||
|
"保存" -> page = "saved"
|
||||||
|
}
|
||||||
|
return FreshActionResult.SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clickAddressEntryFresh(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
|
||||||
|
page = "list"
|
||||||
|
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
|
||||||
|
FreshActionResult.SUCCESS,
|
||||||
|
cn.ilapage.goauto.agent.automation.FreshClickReason.SUCCESS,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||||
|
address = value
|
||||||
|
return FreshActionResult.SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun node(path: String, text: String, bounds: NodeBounds, clickable: Boolean = false, parent: String?) = SnapshotNode(
|
||||||
|
path, parent, text, null, null, "android.widget.TextView", bounds, clickable, false, false, false, true, true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Project-Profile
|
wiki_page: Project-Profile
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||||
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-07T09:27:40Z
|
synchronized_at: 2026-09-21T08:14:21Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 项目档案
|
# 项目档案
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Development-Workflow
|
wiki_page: Development-Workflow
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
|
||||||
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-05T07:16:44Z
|
synchronized_at: 2026-09-21T08:14:27Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 开发工作流
|
# 开发工作流
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Architecture-and-Code-Map
|
wiki_page: Architecture-and-Code-Map
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||||
wiki_revision: 46067d78327476f14e6cfd202458a53f295bf763
|
wiki_revision: 8e2cfa74bc7cf228278221e0f7ef488ac1b596d3
|
||||||
synchronized_at: 2026-09-19T07:18:28Z
|
synchronized_at: 2026-09-21T08:14:31Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 架构与代码地图
|
# 架构与代码地图
|
||||||
@@ -486,6 +486,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
|||||||
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
|
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
|
||||||
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
|
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
|
||||||
|
|
||||||
|
### 会话类失败自动重试(#330)
|
||||||
|
|
||||||
|
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
|
||||||
|
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||||
|
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||||
|
|
||||||
## Chrome PDD 订单回填扩展(#316)
|
## Chrome PDD 订单回填扩展(#316)
|
||||||
|
|
||||||
- `chrome-extension/` 是独立 Manifest V3 交付单元:popup 只负责配置、启动/停止、状态轮询和逐项结果;content script 只在 `mobile.yangkeduo.com` 的隔离世界中按可见 DOM 串行读取;service worker 负责持久运行状态、聚合冲突和分批提交。
|
- `chrome-extension/` 是独立 Manifest V3 交付单元:popup 只负责配置、启动/停止、状态轮询和逐项结果;content script 只在 `mobile.yangkeduo.com` 的隔离世界中按可见 DOM 串行读取;service worker 负责持久运行状态、聚合冲突和分批提交。
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Business-Rules-and-Glossary
|
wiki_page: Business-Rules-and-Glossary
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||||
wiki_revision: b6df3e2d3d497827c9f3cc5b5ec859d5bda3c1f2
|
wiki_revision: 6cb34c99ccdcf4b64b01cb9a25ff1549d8aeeec9
|
||||||
synchronized_at: 2026-09-19T07:39:56Z
|
synchronized_at: 2026-09-21T08:14:37Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 业务规则与术语
|
# 业务规则与术语
|
||||||
@@ -630,6 +630,10 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
|||||||
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
|
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
|
||||||
|
|
||||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||||
|
### SYB 会话类失败的有界自动重试(#330)
|
||||||
|
|
||||||
|
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
||||||
|
|
||||||
## Agent 回填订单入口兼容(#307)
|
## Agent 回填订单入口兼容(#307)
|
||||||
|
|
||||||
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
|
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Local-Development-and-Verification
|
wiki_page: Local-Development-and-Verification
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||||
wiki_revision: f8996a09b2158253553c9e929eab72e1e53e8f61
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-18T07:57:06Z
|
synchronized_at: 2026-09-21T08:14:42Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 本地开发与验证
|
# 本地开发与验证
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Common-Changes
|
wiki_page: Common-Changes
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:30:12Z
|
synchronized_at: 2026-09-21T08:14:50Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 常见修改指南
|
# 常见修改指南
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Troubleshooting
|
wiki_page: Troubleshooting
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-11T09:10:00Z
|
synchronized_at: 2026-09-21T08:14:54Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 故障排查
|
# 故障排查
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Product-Requirements-Overview
|
wiki_page: Product-Requirements-Overview
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:30:20Z
|
synchronized_at: 2026-09-21T08:15:00Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 产品需求总览与当前 MVP
|
# 产品需求总览与当前 MVP
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Android-Agent-API-Contract
|
wiki_page: Android-Agent-API-Contract
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||||
wiki_revision: 275caa306765440b0888dea701198d45481bfb40
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-19T07:40:19Z
|
synchronized_at: 2026-09-21T08:15:05Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- gitea-wiki-mirror:start -->
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Delivery-Issues
|
wiki_page: Delivery-Issues
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:30:28Z
|
synchronized_at: 2026-09-21T08:15:09Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 当前 MVP 交付工单索引
|
# 当前 MVP 交付工单索引
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: OnePlus-Real-Device-Acceptance
|
wiki_page: OnePlus-Real-Device-Acceptance
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:30:32Z
|
synchronized_at: 2026-09-21T08:15:13Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 一加真机验收记录
|
# 一加真机验收记录
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: PDD-Detail-Rule-Migration-Analysis
|
wiki_page: PDD-Detail-Rule-Migration-Analysis
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:30:37Z
|
synchronized_at: 2026-09-21T08:15:17Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# PDD 商品详情采集规则迁移分析
|
# PDD 商品详情采集规则迁移分析
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: SYB-ERP-Interface-Contract
|
wiki_page: SYB-ERP-Interface-Contract
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||||
wiki_revision: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-18T02:18:53Z
|
synchronized_at: 2026-09-21T08:15:30Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 12 顺云宝(SYB)ERP 接口契约
|
# 12 顺云宝(SYB)ERP 接口契约
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Deployment-and-Operations
|
wiki_page: Deployment-and-Operations
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||||
wiki_revision: c208aeeeb4baa3ae2da29f13f3651910c500e360
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-18T07:57:16Z
|
synchronized_at: 2026-09-21T08:14:47Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 部署与运维
|
# 部署与运维
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Home
|
wiki_page: Home
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:29:34Z
|
synchronized_at: 2026-09-21T08:14:18Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# GoAuto 文档中心
|
# GoAuto 文档中心
|
||||||
|
|||||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Deployment-Template
|
wiki_page: Deployment-Template
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:30:41Z
|
synchronized_at: 2026-09-21T08:15:22Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 部署文档模板
|
# 部署文档模板
|
||||||
|
|||||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Task-Archive-Template
|
wiki_page: Task-Archive-Template
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
|
||||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||||
synchronized_at: 2026-09-04T11:30:46Z
|
synchronized_at: 2026-09-21T08:15:26Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
|
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
|
||||||
|
|||||||
Reference in New Issue
Block a user