Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ddb04959 | ||
|
|
ca815c30de | ||
|
|
66e0bdfc07 | ||
|
|
93aab6a59b | ||
|
|
ec4802dfe7 | ||
|
|
ec8b14ae21 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 73
|
||||
versionName = "0.9.60"
|
||||
versionCode = 76
|
||||
versionName = "0.9.63"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+76
-6
@@ -95,6 +95,8 @@ class PurchaseRehearsalExecutor(
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
) {
|
||||
private var purchasePanelContext: PurchasePanelContext? = null
|
||||
private var pageIdentity: Pair<String?, String?> = null to null
|
||||
private var entryRecoveryReason = "not_checked"
|
||||
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
purchasePanelContext = null
|
||||
@@ -376,7 +378,9 @@ class PurchaseRehearsalExecutor(
|
||||
screen = currentScreen(input)
|
||||
}
|
||||
val beforeSignature = specActionSignature(screen)
|
||||
val beforeIdentity = pageIdentity
|
||||
val click = driver.clickFreshDetailed(requireNotNull(target))
|
||||
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
|
||||
when (click.result) {
|
||||
// The parser already narrowed to a single semantic candidate; the
|
||||
// ambiguity here comes from the live tree matching that target more
|
||||
@@ -391,14 +395,22 @@ class PurchaseRehearsalExecutor(
|
||||
var wait = waitForSpecPanel(input, beforeSignature)
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
val refreshed = currentScreen(input)
|
||||
refreshed.problem?.let { return failure(it.code, it.message) }
|
||||
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
if (refreshed.specPanelOpen) return null
|
||||
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
|
||||
if (recoveryTarget == null) {
|
||||
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
|
||||
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
|
||||
when (gesture) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
@@ -412,7 +424,7 @@ class PurchaseRehearsalExecutor(
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
@@ -424,6 +436,48 @@ class PurchaseRehearsalExecutor(
|
||||
)
|
||||
}
|
||||
|
||||
// Whole-page animations are not proof of navigation. Recover only a freshly
|
||||
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
|
||||
private fun recoverableSpecEntry(
|
||||
before: ParsedPddScreen,
|
||||
after: ParsedPddScreen,
|
||||
action: PurchaseAction,
|
||||
identity: Pair<String?, String?>,
|
||||
): SnapshotNode? {
|
||||
entryRecoveryReason = when {
|
||||
identity.first == null || identity.second == null -> "identity_missing"
|
||||
identity != pageIdentity -> "identity_changed"
|
||||
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
|
||||
after.problem != null || after.reviewPageOpen -> "page_problem"
|
||||
after.specPanelOpen -> "panel_open"
|
||||
after.hasCloseControl -> "close_control"
|
||||
after.hasPaymentArea -> "payment_area"
|
||||
after.hasOrderSubmitAction -> "order_action"
|
||||
after.hasQuantityControls -> "quantity_controls"
|
||||
after.hasSelectionSummary -> "selection_summary"
|
||||
before.summary.title.isNullOrBlank() -> "title_missing"
|
||||
before.summary.title != after.summary.title -> "title_changed"
|
||||
after.specEntry == null -> "entry_missing"
|
||||
before.specEntrySource == null -> "entry_source_missing"
|
||||
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
|
||||
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
|
||||
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
|
||||
else -> "eligible"
|
||||
}
|
||||
if (entryRecoveryReason != "eligible") return null
|
||||
val anchor = after.specEntry ?: return null
|
||||
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
|
||||
entryRecoveryReason = "rule_alias_mismatch"
|
||||
return null
|
||||
}
|
||||
val target = after.specEntryClickTarget ?: anchor
|
||||
if (!target.visible || !target.enabled) {
|
||||
entryRecoveryReason = "entry_unavailable"
|
||||
return null
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private data class SpecPanelWait(
|
||||
val screen: ParsedPddScreen,
|
||||
val opened: Boolean,
|
||||
@@ -549,6 +603,7 @@ class PurchaseRehearsalExecutor(
|
||||
): PurchaseExecutionOutcome? {
|
||||
val initial = currentScreen(input)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
unavailableExactSpec(initial, dimension, target)?.let { return it }
|
||||
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
|
||||
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
@@ -623,7 +678,9 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
|
||||
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
|
||||
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
|
||||
return dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes)
|
||||
}
|
||||
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
|
||||
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
|
||||
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
|
||||
@@ -797,6 +854,18 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
|
||||
|
||||
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
|
||||
if (!screen.specPanelOpen) return null
|
||||
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
|
||||
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
|
||||
}
|
||||
|
||||
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
|
||||
val role = if (dimension == "color") "颜色" else "尺码"
|
||||
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
|
||||
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches only parsed, selectable values in the already-open spec panel.
|
||||
* A short downward pass first restores the top when a previous action left
|
||||
@@ -833,7 +902,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
|
||||
if (exact.size == 1) {
|
||||
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
|
||||
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
|
||||
}
|
||||
val signature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
@@ -898,7 +967,7 @@ class PurchaseRehearsalExecutor(
|
||||
val lookup = when {
|
||||
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
|
||||
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
|
||||
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
|
||||
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
|
||||
else -> null
|
||||
}
|
||||
val rows = specValueRows(values)
|
||||
@@ -1134,6 +1203,7 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
val snapshot = driver.capture()
|
||||
pageIdentity = snapshot.packageName to snapshot.activityName
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** A narrow exception for one selected text option duplicated by its card/image. */
|
||||
internal object SelectedColorCard {
|
||||
fun confirms(target: String, values: List<VisibleSpecValue>, source: List<SnapshotNode>): Boolean {
|
||||
val exact = values.filter { it.text == target }.singleOrNull() ?: return false
|
||||
if (!exact.node.selected && !exact.node.checked) return false
|
||||
val byPath = source.associateBy { it.path }
|
||||
fun contains(outer: NodeBounds, inner: NodeBounds) =
|
||||
inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top &&
|
||||
inner.right <= outer.right && inner.bottom <= outer.bottom
|
||||
fun cardFor(node: SnapshotNode): SnapshotNode? {
|
||||
var current = node
|
||||
val seen = mutableSetOf<String>()
|
||||
while (seen.add(current.path)) {
|
||||
if (current.scrollable || !current.visible || !current.enabled) return null
|
||||
val parent = current.parentPath?.let(byPath::get) ?: return null
|
||||
if (parent.scrollable) {
|
||||
return current.takeIf {
|
||||
it.clickable && it.className == "android.view.ViewGroup" &&
|
||||
contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds)
|
||||
}
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
val card = cardFor(exact.node) ?: return false
|
||||
// The parser may have deduplicated identical labels across cards. Check
|
||||
// the source as well before treating a selected duplicate as harmless.
|
||||
if (source.any { node ->
|
||||
node.visible && node.enabled && node.clickable &&
|
||||
SpecValueNormalizer.normalizeColor(node.label) == target &&
|
||||
cardFor(node)?.let { it.path != card.path } == true
|
||||
}) return false
|
||||
return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other ->
|
||||
val node = other.node
|
||||
// Never merge another textual option, adjacent card, or scroll container.
|
||||
(node.path == card.path || node.className == "android.widget.ImageView") &&
|
||||
cardFor(node)?.path == card.path && contains(card.bounds, node.bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
-1
@@ -883,6 +883,84 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertTrue(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected color card duplicate does not trigger another selection tap`() {
|
||||
val driver = FakePurchaseDriver(duplicateColorCardEvidence = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
|
||||
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
|
||||
val logs = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
|
||||
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic product with close control never receives recovery tap`() {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
|
||||
specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changed entry identity or unsafe page never receives recovery tap`() {
|
||||
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(mode, "failed", outcome.resultType)
|
||||
assertEquals(mode, 0, driver.specTapCount)
|
||||
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
|
||||
"activity" to "identity_changed", "payment" to "payment_area").getValue(mode)
|
||||
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unavailable exact color stops without selection click or searching even when selected`() {
|
||||
for (selected in listOf(null, "黑色")) {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
|
||||
unavailableColors = setOf("黑色")).apply { color = selected }
|
||||
val logs = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
|
||||
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
|
||||
assertFalse(driver.clicked.contains("黑色"))
|
||||
assertEquals(0, driver.specTapCount)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
|
||||
assertFalse(logs.any { it.contains("黑色") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged spec entry after action and gesture returns no effect`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
@@ -1308,6 +1386,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val forcedEntryClickReason: FreshClickReason? = null,
|
||||
private val initialSize: String? = null,
|
||||
private val entryActionHasEffect: Boolean = true,
|
||||
private val duplicateColorCardEvidence: Boolean = false,
|
||||
private val dynamicProduct: Boolean = false,
|
||||
private val productOverlay: Boolean = false,
|
||||
private val afterEntryMutation: String = "",
|
||||
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
|
||||
private val specTapHasEffect: Boolean = true,
|
||||
private val sizeSelectsOnFailedClick: Boolean = false,
|
||||
@@ -1325,6 +1407,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val pullDownSucceeds: Boolean = true,
|
||||
private val loseEvidenceAfterPull: Boolean = false,
|
||||
private val unavailableSizes: Set<String> = emptySet(),
|
||||
private val unavailableColors: Set<String> = emptySet(),
|
||||
allSpecsUnavailable: Boolean = false,
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val confirmationPanelKind: String? = null,
|
||||
@@ -1361,6 +1444,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
private var horizontalColorPage = 0
|
||||
private var horizontalSizePage = 0
|
||||
private var capturesAfterSizeSelection = 0
|
||||
private var entryAttempted = false
|
||||
val clicked = mutableListOf<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
|
||||
@@ -1423,6 +1507,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
|
||||
)
|
||||
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
|
||||
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
|
||||
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
|
||||
if (bottomPurchaseEntry && specEntryReady) {
|
||||
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
|
||||
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
|
||||
@@ -1431,6 +1517,12 @@ class PurchaseRehearsalExecutorTest {
|
||||
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
|
||||
}
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
if (entryAttempted) {
|
||||
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
|
||||
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
|
||||
if (afterEntryMutation == "payment") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
|
||||
}
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
if (unrecognizedPanel) {
|
||||
@@ -1473,6 +1565,16 @@ class PurchaseRehearsalExecutorTest {
|
||||
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
|
||||
}
|
||||
visibleColors.forEachIndexed { index, value ->
|
||||
if (duplicateColorCardEvidence && selectedColor == value) {
|
||||
val cardPath = "scroll/card-$index"
|
||||
nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550,
|
||||
clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup")
|
||||
nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500,
|
||||
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView")
|
||||
nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540,
|
||||
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView")
|
||||
return@forEachIndexed
|
||||
}
|
||||
nodes += node(
|
||||
"scroll/color-$index",
|
||||
value,
|
||||
@@ -1482,7 +1584,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
540,
|
||||
clickable = true,
|
||||
selected = selectedColor == value,
|
||||
enabled = !allSpecsUnavailable,
|
||||
enabled = !allSpecsUnavailable && value !in unavailableColors,
|
||||
parentPath = colorParent,
|
||||
)
|
||||
}
|
||||
@@ -1570,6 +1672,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
if (target.path in setOf("spec", "buy")) entryAttempted = true
|
||||
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
|
||||
val result = when (forcedEntryClickReason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SelectedColorCardTest {
|
||||
private val target = "黑色两件套 19"
|
||||
private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true,
|
||||
scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind,
|
||||
NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true)
|
||||
private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true)
|
||||
private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup")
|
||||
private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView")
|
||||
private val text = node("s/c/t", "s/c", target, "android.widget.TextView")
|
||||
private fun check(nodes: List<SnapshotNode>, candidates: List<SnapshotNode> = nodes.filter { it != scroll }) =
|
||||
SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes)
|
||||
|
||||
@Test fun `selected target and own card image duplicates are one selection`() {
|
||||
assertTrue(check(listOf(scroll, card, image, text)))
|
||||
}
|
||||
@Test fun `another selected card remains conflict`() {
|
||||
val other = card.copy(path = "s/other")
|
||||
assertFalse(check(listOf(scroll, card, image, text, other)))
|
||||
}
|
||||
@Test fun `two exact targets are not merged`() {
|
||||
val otherCard = card.copy(path = "s/other", selected = false)
|
||||
val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path)
|
||||
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText)))
|
||||
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text)))
|
||||
}
|
||||
@Test fun `unselected target cannot borrow selection from its container`() {
|
||||
assertFalse(check(listOf(scroll, card, image, text.copy(selected = false))))
|
||||
}
|
||||
@Test fun `scrollable or missing card boundary cannot merge`() {
|
||||
assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text)))
|
||||
assertFalse(check(listOf(card, image, text)))
|
||||
assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text)))
|
||||
}
|
||||
@Test fun `different text sibling is not treated as image duplicate`() {
|
||||
assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView"))))
|
||||
}
|
||||
@Test fun `out of card bounds and parent cycle are rejected`() {
|
||||
assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text)))
|
||||
assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text)))
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: a53adfd64faa86b21c800bb11ae95aa73833b107
|
||||
synchronized_at: 2026-09-07T13:34:52Z
|
||||
wiki_revision: fdcce403423aa799d1517ac75fb38da636ef5217
|
||||
synchronized_at: 2026-09-09T01:22:58Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-07T13:34:52Z
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
@@ -67,7 +74,7 @@ synchronized_at: 2026-09-07T13:34:52Z
|
||||
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
|
||||
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
|
||||
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
|
||||
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
|
||||
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,已于 2026-09-08 随 d403f3b 部署线上)。
|
||||
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
|
||||
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
|
||||
|
||||
@@ -397,7 +404,7 @@ synchronized_at: 2026-09-07T13:34:52Z
|
||||
|
||||
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
|
||||
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
|
||||
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
|
||||
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
|
||||
## PDD 商品反向关联与继续订单采购(#161)
|
||||
@@ -447,7 +454,7 @@ synchronized_at: 2026-09-07T13:34:52Z
|
||||
|
||||
## 客户端密钥与可编辑模块授权(#237)
|
||||
|
||||
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,线上尚未部署,真实客户端密钥执行闭环尚未验证。
|
||||
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,2026-09-08 已随 d403f3b 完成线上迁移与部署,真实客户端密钥执行闭环尚未验证。
|
||||
|
||||
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
|
||||
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
|
||||
@@ -465,3 +472,32 @@ synchronized_at: 2026-09-07T13:34:52Z
|
||||
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
|
||||
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
|
||||
- 其他动作的后置滑动沿用原行为,必需滑动失败仍返回 RULE_ACTION_FAILED。旧规则 JSON 不回写、不迁移;原任务 ID、历史 attempt、商品与规格快照不变。旧 APK 行为不变,需升级 Agent;本单未改线上规则或执行 CG68 真机采购。
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
|
||||
## 商品页动态变化下的采购入口恢复(#243)
|
||||
|
||||
实现 ec8b14a,Android 0.9.61/versionCode 74;已构建、未安装或真机验收。规格探测和正式采购共用入口函数,面板已打开时不新增点击;仍在同一商品上下文且安全入口存在时,一次重新定位手势恢复不再依赖整页完全静止。手势后仍未打开则报告入口无效果,不循环点击。恢复不能用于地址、创建订单、支付或未知面板,不修改面板分类器及正式下单边界。CG82/CG85 原首次点击未生效的底层原因尚待新日志和授权真机验证。
|
||||
|
||||
|
||||
## 同一颜色卡片重复选中节点(#244)
|
||||
|
||||
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
|
||||
|
||||
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
|
||||
|
||||
|
||||
## 精确规格不可用与入口恢复诊断(#245)
|
||||
|
||||
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
|
||||
|
||||
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
|
||||
|
||||
Reference in New Issue
Block a user