Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a953a5e9fb | ||
|
|
9c7357609c | ||
|
|
3ee8dfc0aa | ||
|
|
555a954605 | ||
|
|
1e924fa411 | ||
|
|
1c67f3fdd0 | ||
|
|
6f1af70d0d | ||
|
|
92aeab8d04 | ||
|
|
c9b7a2dc0d | ||
|
|
dbf9a5f460 | ||
|
|
8f07a19512 | ||
|
|
2c58d5817c | ||
|
|
e26743c817 | ||
|
|
e5feab8d6b | ||
|
|
0ec8497672 | ||
|
|
954552ed11 | ||
|
|
9e5f09abc5 | ||
|
|
fcd6426f80 | ||
|
|
15ccab3960 |
@@ -30,6 +30,9 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = "-373-fix1"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
<application android:usesCleartextTraffic="true">
|
||||
<receiver android:name=".diagnostics.NavigationProbeReceiver"
|
||||
android:exported="true" android:permission="android.permission.DUMP" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
internal object BrowserProbeClickPolicy {
|
||||
fun allowed(rootPackage: String?, nodePackage: String?, label: String, visible: Boolean,
|
||||
enabled: Boolean, clickable: Boolean, rootWindow: Int, nodeWindow: Int): Boolean =
|
||||
rootPackage in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser") &&
|
||||
nodePackage == rootPackage && label in setOf("打开拼多多APP", "打开拼多多 App") &&
|
||||
visible && enabled && clickable && rootWindow >= 0 && nodeWindow == rootWindow
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode as UiSnapshotNode
|
||||
|
||||
internal class NavigationProbe(
|
||||
private val capture: () -> UiSnapshot,
|
||||
private val launch: ((PddLaunchEvent) -> Unit) -> Boolean,
|
||||
private val clickBrowserEntry: (UiSnapshotNode) -> FreshClickOutcome,
|
||||
private val now: () -> Long,
|
||||
private val pause: (Long) -> Unit,
|
||||
private val emit: (String) -> Unit,
|
||||
private val windowId: () -> Int? = { null },
|
||||
private val isActive: () -> Boolean = { true },
|
||||
) {
|
||||
fun run() {
|
||||
val started = now()
|
||||
fun canAct() = isActive() && !Thread.currentThread().isInterrupted && now() - started < 20_000
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
val before = capture()
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
fun problem(page: UiSnapshot) = PddPageClassifier.classify(page.packageName, page.activityName,
|
||||
page.nodes.filter { it.visible }.map { it.label }) != null
|
||||
if (problem(before)) { emit("stopped=page_problem"); return }
|
||||
val initial = PddScreenParser.parse(before, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
// Values remain in memory. Neither text nor its hash is logged/persisted.
|
||||
val oldTitle = initial.summary.title
|
||||
val oldWindow = windowId()
|
||||
val initiallyPdd = before.packageName == "com.xunmeng.pinduoduo"
|
||||
emit("baseline;pdd=$initiallyPdd;root=${initial.rootAvailable};product=${initiallyPdd && initial.hasPurchaseProductEvidence()};title=${oldTitle != null};window=$oldWindow")
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
if (!launch { event ->
|
||||
emit("launch;t=${now() - started};path=${event.path};status=${event.status};exception=${event.exceptionClass ?: "none"}")
|
||||
}) { emit("stopped=launch_failed"); return }
|
||||
var stable = 0
|
||||
var legacyAccepted = false
|
||||
var browserClicks = 0
|
||||
var nextBrowserClick = 0L
|
||||
var polls = 0
|
||||
var lastSignal: String? = null
|
||||
while (canAct() && polls < 201) {
|
||||
val readStarted = now()
|
||||
val page = capture()
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired;t=${now() - started}"); return }
|
||||
if (problem(page)) { emit("stopped=page_problem;t=${now() - started}"); return }
|
||||
val screen = PddScreenParser.parse(page, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
val product = page.packageName == "com.xunmeng.pinduoduo" && screen.hasPurchaseProductEvidence()
|
||||
stable = if (product) stable + 1 else 0
|
||||
val titleComparison = when {
|
||||
oldTitle == null || screen.summary.title == null -> "unknown"
|
||||
oldTitle == screen.summary.title -> "same"
|
||||
else -> "different"
|
||||
}
|
||||
val category = when (page.packageName) {
|
||||
"com.xunmeng.pinduoduo" -> "pdd"
|
||||
in BROWSERS -> "browser"
|
||||
null -> "none"
|
||||
else -> "other"
|
||||
}
|
||||
val signal = "pkg=$category;root=${screen.rootAvailable};product=$product;panel=${screen.specPanelOpen};entry=${screen.specEntry != null};nodes=${page.nodes.size};title=$titleComparison;window=${windowId()}"
|
||||
if (signal != lastSignal || polls % 10 == 0) {
|
||||
emit("sample;t=${now() - started};readMs=${now() - readStarted};$signal")
|
||||
lastSignal = signal
|
||||
}
|
||||
// Observation only: never treats this as proof of goods identity or calls the executor.
|
||||
if (stable >= 2 && !legacyAccepted) {
|
||||
legacyAccepted = true
|
||||
emit("legacy_would_accept;t=${now() - started};title=$titleComparison;identityVerified=false")
|
||||
}
|
||||
if (category == "browser") {
|
||||
val entries = page.nodes.filter { it.visible && it.enabled && it.label in OPEN_PDD_ALIASES }
|
||||
if (entries.size > 1) { emit("stopped=browser_ambiguous"); return }
|
||||
if (entries.size == 1 && browserClicks < 3 && now() >= nextBrowserClick) {
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
val result = clickBrowserEntry(entries.single())
|
||||
browserClicks++
|
||||
emit("browser_click;t=${now() - started};attempt=$browserClicks;result=${result.result};reason=${result.reason}")
|
||||
if (result.result == FreshActionResult.AMBIGUOUS || result.result == FreshActionResult.BLOCKED) {
|
||||
emit("stopped=browser_click_unsafe"); return
|
||||
}
|
||||
nextBrowserClick = now() + 1_000
|
||||
}
|
||||
}
|
||||
polls++
|
||||
pause(100)
|
||||
}
|
||||
emit("finished;t=${now() - started};samples=$polls;legacyAccepted=$legacyAccepted;identityVerified=false")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val BROWSERS = setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
|
||||
private val OPEN_PDD_ALIASES = setOf("打开拼多多APP", "打开拼多多 App")
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import java.util.UUID
|
||||
|
||||
/** Debug APK only; manifest requires the shell/system DUMP permission. No arbitrary URL or action. */
|
||||
class NavigationProbeReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val goodsId = intent.getStringExtra("goodsId").orEmpty()
|
||||
val mode = intent.getStringExtra("mode").orEmpty()
|
||||
if (!goodsId.matches(Regex("[1-9][0-9]{0,19}")) || mode !in setOf("direct", "browser")) {
|
||||
Log.i(TAG, "rejected=invalid_arguments")
|
||||
return
|
||||
}
|
||||
val service = AgentForegroundService.diagnosticInstance
|
||||
val accessibility = GoAutoAccessibilityService.instance
|
||||
if (service == null || accessibility == null) {
|
||||
Log.i(TAG, "rejected=service_unavailable")
|
||||
return
|
||||
}
|
||||
val runId = UUID.randomUUID().toString()
|
||||
val accepted = service.runNavigationProbe { serviceContext ->
|
||||
// Delayed execution must not use a destroyed accessibility instance.
|
||||
if (GoAutoAccessibilityService.instance !== accessibility) {
|
||||
Log.i(TAG, "run=$runId;stopped=accessibility_changed")
|
||||
return@runNavigationProbe
|
||||
}
|
||||
Log.i(TAG, "run=$runId;started;mode=$mode;synthetic=true;noBusinessTask=true")
|
||||
val deadline = SystemClock.elapsedRealtime() + 20_000
|
||||
val canAct = { AgentForegroundService.diagnosticInstance === service &&
|
||||
GoAutoAccessibilityService.instance === accessibility && !Thread.currentThread().isInterrupted &&
|
||||
SystemClock.elapsedRealtime() < deadline }
|
||||
val guardedContext = object : ContextWrapper(serviceContext) {
|
||||
override fun startActivity(intent: Intent) {
|
||||
// Includes direct-failure fallback and package-manager Binder delays.
|
||||
check(canAct()) { "probe_inactive" }
|
||||
super.startActivity(intent)
|
||||
}
|
||||
}
|
||||
NavigationProbe(
|
||||
capture = {
|
||||
check(canAct()) { "probe_inactive" }
|
||||
accessibility.capture()
|
||||
},
|
||||
launch = { report -> canAct() && PddLinkLauncher(guardedContext).open(
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=$goodsId", mode == "direct", report,
|
||||
) },
|
||||
clickBrowserEntry = { safeBrowserClick(accessibility, canAct) },
|
||||
now = SystemClock::elapsedRealtime,
|
||||
pause = { Thread.sleep(it) },
|
||||
emit = { Log.i(TAG, "run=$runId;$it") },
|
||||
windowId = { accessibility.rootInActiveWindow?.let { root ->
|
||||
try { root.windowId } finally { @Suppress("DEPRECATION") root.recycle() }
|
||||
} },
|
||||
isActive = canAct,
|
||||
).run()
|
||||
}
|
||||
if (!accepted) Log.i(TAG, "run=$runId;rejected=device_busy")
|
||||
}
|
||||
|
||||
/** Fresh native node only: no ancestors, gestures, coordinates, or PDD-package actions. */
|
||||
private fun safeBrowserClick(service: GoAutoAccessibilityService, canAct: () -> Boolean): FreshClickOutcome {
|
||||
fun blocked() = FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.TARGET_NOT_FOUND)
|
||||
if (!canAct()) return blocked()
|
||||
val root = service.rootInActiveWindow ?: return blocked()
|
||||
val nodes = mutableListOf<AccessibilityNodeInfo>()
|
||||
fun visit(node: AccessibilityNodeInfo) {
|
||||
nodes += node
|
||||
if (nodes.size >= 2_000 || !canAct()) return
|
||||
for (i in 0 until node.childCount) {
|
||||
if (nodes.size >= 2_000 || !canAct()) break
|
||||
node.getChild(i)?.let(::visit)
|
||||
}
|
||||
}
|
||||
try {
|
||||
visit(root)
|
||||
if (nodes.size >= 2_000 || !canAct()) return blocked()
|
||||
val candidates = nodes.filter { node ->
|
||||
val label = node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() }
|
||||
?: node.contentDescription?.toString()?.trim().orEmpty()
|
||||
BrowserProbeClickPolicy.allowed(root.packageName?.toString(), node.packageName?.toString(), label,
|
||||
node.isVisibleToUser, node.isEnabled, node.isClickable, root.windowId, node.windowId)
|
||||
}
|
||||
if (candidates.size != 1) return blocked()
|
||||
val current = service.rootInActiveWindow ?: return blocked()
|
||||
val sameWindow = try { current.windowId == root.windowId && current.packageName == root.packageName }
|
||||
finally { @Suppress("DEPRECATION") current.recycle() }
|
||||
if (!sameWindow || !canAct()) return blocked()
|
||||
val clicked = candidates.single().performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
return FreshClickOutcome(if (clicked) FreshActionResult.SUCCESS else FreshActionResult.FAILED,
|
||||
if (clicked) FreshClickReason.SUCCESS else FreshClickReason.ACTION_CLICK_FALSE)
|
||||
} finally {
|
||||
nodes.forEach { @Suppress("DEPRECATION") it.recycle() }
|
||||
}
|
||||
}
|
||||
private companion object { const val TAG = "GoAutoNavProbe" }
|
||||
}
|
||||
+12
-1
@@ -122,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
).restore(packageName, timeoutMillis)
|
||||
|
||||
fun openAgentPreservingTab(): Boolean = runCatching {
|
||||
foregroundRevision.incrementAndGet() // Explicit navigation invalidates handoff even before its window event arrives.
|
||||
dismissPurchaseResultBubble()
|
||||
startActivity(
|
||||
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
|
||||
@@ -340,7 +341,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
labels = { listOfNotNull(it.text?.toString(), it.contentDescription?.toString()) },
|
||||
)
|
||||
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome = clickFreshDetailed(target) { true }
|
||||
|
||||
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome =
|
||||
clickFreshDetailed(target) { node ->
|
||||
val targetIsBrowser = PurchaseNavigationReadiness.isBrowser(node.packageName?.toString())
|
||||
val foregroundIsBrowser = PurchaseNavigationReadiness.isBrowser(currentPackage())
|
||||
targetIsBrowser && foregroundIsBrowser && allowed()
|
||||
}
|
||||
|
||||
private fun clickFreshDetailed(target: SnapshotNode, beforeClick: (AccessibilityNodeInfo) -> Boolean): FreshClickOutcome {
|
||||
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
@@ -370,6 +380,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
|
||||
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, candidates.size, ancestorDepth,
|
||||
)
|
||||
if (!beforeClick(node)) return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
|
||||
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
enum class PddLaunchPath { DIRECT, BROWSER }
|
||||
enum class PddLaunchStatus { ACCEPTED, REJECTED, EXCEPTION }
|
||||
|
||||
/** No URL, exception message, or page content may enter this event. */
|
||||
data class PddLaunchEvent(val path: PddLaunchPath, val status: PddLaunchStatus, val exceptionClass: String? = null)
|
||||
|
||||
internal object PddLaunchFallback {
|
||||
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
|
||||
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
|
||||
return runCatching(browser).getOrDefault(false)
|
||||
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean,
|
||||
diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
|
||||
fun attempt(path: PddLaunchPath, launch: () -> Boolean): Boolean {
|
||||
val result = runCatching(launch)
|
||||
val event = PddLaunchEvent(path, when {
|
||||
result.isFailure -> PddLaunchStatus.EXCEPTION
|
||||
result.getOrDefault(false) -> PddLaunchStatus.ACCEPTED
|
||||
else -> PddLaunchStatus.REJECTED
|
||||
}, result.exceptionOrNull()?.javaClass?.name)
|
||||
// Observability must never change routing or turn an accepted launch into a retry.
|
||||
runCatching { diagnostic(event) }
|
||||
return result.getOrDefault(false)
|
||||
}
|
||||
if (preferDirect && attempt(PddLaunchPath.DIRECT, direct)) return true
|
||||
return attempt(PddLaunchPath.BROWSER, browser)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ object PddPageClassifier {
|
||||
}
|
||||
|
||||
class PddLinkLauncher(private val context: Context) {
|
||||
fun open(url: String, preferDirect: Boolean = false): Boolean {
|
||||
fun open(url: String, preferDirect: Boolean = false, diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
|
||||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
|
||||
return false
|
||||
@@ -42,7 +42,7 @@ class PddLinkLauncher(private val context: Context) {
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
context.startActivity(direct)
|
||||
true
|
||||
}, browser = { openBrowser(uri) })
|
||||
}, browser = { openBrowser(uri) }, diagnostic = diagnostic)
|
||||
}
|
||||
|
||||
private fun openBrowser(uri: Uri): Boolean {
|
||||
@@ -51,7 +51,9 @@ class PddLinkLauncher(private val context: Context) {
|
||||
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
|
||||
}
|
||||
if (browser != null) base.setPackage(browser)
|
||||
return runCatching { context.startActivity(base); true }.getOrDefault(false)
|
||||
// The outer fallback boundary retains the previous false-on-error behavior and records only the class.
|
||||
context.startActivity(base)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isPddHost(host: String?): Boolean =
|
||||
|
||||
+92
-9
@@ -167,6 +167,8 @@ data class ParsedPddScreen(
|
||||
val quantityInputCount: Int = 0,
|
||||
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
|
||||
val finalSelectionSummaries: List<String> = emptyList(),
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
@@ -265,7 +267,7 @@ object PddScreenParser {
|
||||
/** 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, forFinalConfirmation: Boolean = false): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
val descendants = descendants(node, visibleNodes)
|
||||
@@ -422,9 +424,12 @@ object PddScreenParser {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
.groupBy(VisibleSpecValue::rawText)
|
||||
.map { (_, sameLabel) ->
|
||||
sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) }
|
||||
.let { physicalOptions ->
|
||||
// Keep optionBlock's physical parent/child aggregation in both modes.
|
||||
// Only final confirmation must detect separate same-label option blocks.
|
||||
if (forFinalConfirmation) physicalOptions else physicalOptions
|
||||
.groupBy(VisibleSpecValue::rawText)
|
||||
.map { (_, sameLabel) -> sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } }
|
||||
}
|
||||
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey, heading.label, values))
|
||||
}
|
||||
@@ -471,6 +476,7 @@ object PddScreenParser {
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
var unprefixedSummaryCandidates = emptyList<String>()
|
||||
val unprefixedSummary = if (structuredSelectionPanel) {
|
||||
val quantity = quantityInputs.single()
|
||||
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
|
||||
@@ -486,6 +492,7 @@ object PddScreenParser {
|
||||
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
|
||||
}.map { it.label }.distinct()
|
||||
if (candidates.isNotEmpty()) {
|
||||
unprefixedSummaryCandidates = candidates
|
||||
summary = candidates.singleOrNull()
|
||||
break
|
||||
}
|
||||
@@ -574,6 +581,12 @@ object PddScreenParser {
|
||||
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||
quantityInputCount = quantityInputs.size,
|
||||
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||
finalSelectionSummaries = if (forFinalConfirmation) {
|
||||
// Use own node labels, not labels propagated into clickable ancestors.
|
||||
(visibleNodes.map(SnapshotNode::label).filter { label ->
|
||||
textAliases.selection.selectedPrefixes.any { label.startsWith(it) }
|
||||
} + unprefixedSummaryCandidates).distinct()
|
||||
} else emptyList(),
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
@@ -1392,6 +1405,12 @@ class PddProductDetailCollector(
|
||||
var diagnosticInitialSelectedSizes = 0
|
||||
var diagnosticSelectedSummaryPresent = false
|
||||
var diagnosticHorizontalSwipes = 0
|
||||
var diagnosticVerticalSwipes = 0
|
||||
var diagnosticTrailingEmptyReads = 0
|
||||
var diagnosticHorizontalUnknown = false
|
||||
// Exact seed sets can reconcile a later stable visit to the same row.
|
||||
// These signatures stay in memory; only the aggregate tri-state is saved.
|
||||
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
|
||||
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
||||
val imageAttempts = mutableSetOf<String>()
|
||||
|
||||
@@ -1448,6 +1467,13 @@ class PddProductDetailCollector(
|
||||
initialSelectedSizeCount = diagnosticInitialSelectedSizes,
|
||||
selectedSummaryPresent = diagnosticSelectedSummaryPresent,
|
||||
horizontalSwipes = diagnosticHorizontalSwipes,
|
||||
verticalSwipes = diagnosticVerticalSwipes,
|
||||
trailingEmptyReads = diagnosticTrailingEmptyReads,
|
||||
horizontalIncomplete = when {
|
||||
diagnosticHorizontalRows.values.any { it == true } -> true
|
||||
diagnosticHorizontalUnknown || diagnosticHorizontalRows.values.any { it == null } -> null
|
||||
else -> false
|
||||
},
|
||||
elapsedMs = now() - diagnosticStartedAt,
|
||||
)
|
||||
}
|
||||
@@ -1464,6 +1490,8 @@ class PddProductDetailCollector(
|
||||
val initialRows = colorRows(initial)
|
||||
observeColorDiscovery(initial, initialRows)
|
||||
if (initialRows.isEmpty()) {
|
||||
diagnosticTrailingEmptyReads = 1
|
||||
diagnosticHorizontalUnknown = true
|
||||
finishColorDiscovery(
|
||||
if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE
|
||||
else AgentDiagnosticReason.COLOR_EDGE_REACHED,
|
||||
@@ -1471,7 +1499,8 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
val attempted = mutableSetOf<String>()
|
||||
fun collectVisibleColors(): PddCollectorResult? {
|
||||
fun collectVisibleColors(onFirstRows: ((List<List<VisibleSpecValue>>) -> Unit)? = null): PddCollectorResult? {
|
||||
var firstRead = true
|
||||
while (true) {
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
@@ -1479,6 +1508,10 @@ class PddProductDetailCollector(
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
||||
val rows = colorRows(screen)
|
||||
if (firstRead) {
|
||||
onFirstRows?.invoke(rows)
|
||||
firstRead = false
|
||||
}
|
||||
observeColorDiscovery(screen, rows)
|
||||
rows.flatten().filter { SpecValueNormalizer.isSafeColor(it.text) }.forEach { value ->
|
||||
colors[value.text] = colors[value.text] == true || value.available
|
||||
@@ -1531,8 +1564,18 @@ class PddProductDetailCollector(
|
||||
rowSeeds.forEachIndexed { rowIndex, seed ->
|
||||
val moveRight = rowIndex % 2 == 0
|
||||
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
|
||||
var diagnosticPreviousSignature: List<String>? = null
|
||||
var diagnosticStableReads = 0
|
||||
var diagnosticMoved = false
|
||||
var diagnosticRowUncertain = false
|
||||
var diagnosticRowIncomplete: Boolean? = null
|
||||
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
|
||||
collectVisibleColors()?.let { return it }
|
||||
var diagnosticBeforeClicks: List<String>? = null
|
||||
collectVisibleColors { observedRows ->
|
||||
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
|
||||
.singleOrNull()?.sortedBy { it.node.bounds.left }
|
||||
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
|
||||
}?.let { return it }
|
||||
screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
@@ -1541,21 +1584,49 @@ class PddProductDetailCollector(
|
||||
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
|
||||
?.takeIf { row -> row.any { it.text in seed } }
|
||||
if (matchedRow == null) {
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
|
||||
break
|
||||
}
|
||||
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
|
||||
.let { if (moveRight) it else it.reversed() }
|
||||
val signature = optionSignature(currentRow)
|
||||
// Observe the frame immediately after the prior swipe,
|
||||
// before clicking newly visible values. A click reflow or
|
||||
// ambiguous row match cannot prove horizontal movement.
|
||||
if (diagnosticBeforeClicks == null || diagnosticBeforeClicks != signature) {
|
||||
diagnosticRowUncertain = true
|
||||
diagnosticHorizontalUnknown = true
|
||||
}
|
||||
if (!diagnosticRowUncertain && diagnosticPreviousSignature != null && signature != diagnosticPreviousSignature) {
|
||||
diagnosticMoved = true
|
||||
}
|
||||
diagnosticStableReads = if (signature == diagnosticPreviousSignature) diagnosticStableReads + 1 else 0
|
||||
diagnosticPreviousSignature = signature
|
||||
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
|
||||
horizontalSignatureReads[signature] = signatureReads
|
||||
if (signatureReads > config.limits.getValue("stableEdgeReads")) break
|
||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) break
|
||||
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
|
||||
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticRowIncomplete = false
|
||||
}
|
||||
break
|
||||
}
|
||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
||||
if (!driver.swipeSpec(direction, currentRow.first().node)) break
|
||||
if (!driver.swipeSpec(direction, currentRow.first().node)) {
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
diagnosticHorizontalSwipes++
|
||||
pause(350)
|
||||
}
|
||||
// An uncertain revisit cannot erase previously observed
|
||||
// unresolved movement; only reliable stability clears it.
|
||||
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
|
||||
?: diagnosticHorizontalRows[seed]?.takeIf { it }
|
||||
}
|
||||
collectVisibleColors()?.let { return it }
|
||||
screen = parse(goodsId, config, evidence)
|
||||
@@ -1564,6 +1635,10 @@ class PddProductDetailCollector(
|
||||
observeColorDiscovery(screen, rows)
|
||||
}
|
||||
|
||||
// One observation per outer discovery round, never per click or
|
||||
// stable-price reread. Empty parsed rows do not prove an edge.
|
||||
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
|
||||
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
|
||||
if (!discoverVertically) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
break
|
||||
@@ -1586,6 +1661,7 @@ class PddProductDetailCollector(
|
||||
}
|
||||
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
|
||||
if (anchor == null) {
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
|
||||
break
|
||||
}
|
||||
@@ -1593,6 +1669,7 @@ class PddProductDetailCollector(
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
|
||||
break
|
||||
}
|
||||
diagnosticVerticalSwipes++
|
||||
pause(350)
|
||||
}
|
||||
finishColorDiscovery()
|
||||
@@ -1621,6 +1698,9 @@ class PddProductDetailCollector(
|
||||
initialSelectedSizeCount: Int,
|
||||
selectedSummaryPresent: Boolean,
|
||||
horizontalSwipes: Int,
|
||||
verticalSwipes: Int,
|
||||
trailingEmptyReads: Int,
|
||||
horizontalIncomplete: Boolean?,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (taskId <= 0) return
|
||||
@@ -1639,6 +1719,9 @@ class PddProductDetailCollector(
|
||||
initialSelectedSizeCount = initialSelectedSizeCount,
|
||||
selectedSummaryPresent = selectedSummaryPresent,
|
||||
horizontalSwipeCount = horizontalSwipes,
|
||||
colorVerticalSwipeCount = verticalSwipes,
|
||||
colorTrailingEmptyReadCount = trailingEmptyReads,
|
||||
colorHorizontalIncomplete = horizontalIncomplete,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+58
-12
@@ -3,6 +3,7 @@ package cn.ilapage.goauto.agent.automation
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import org.json.JSONObject
|
||||
|
||||
data class ShippingAddressProof internal constructor(
|
||||
internal val expectedAddress: String,
|
||||
@@ -17,7 +18,28 @@ data class FinalConfirmationEvidence(
|
||||
val unitPriceCent: Long,
|
||||
val addressSuffix: String,
|
||||
val activityName: String,
|
||||
)
|
||||
val observedQuantity: Long? = null,
|
||||
val selectedSummaryPresent: Boolean = false,
|
||||
val summaryPromptPresent: Boolean = false,
|
||||
) {
|
||||
/** Local boundary evidence only; never used in the uploaded purchase result. */
|
||||
fun toLocalJson(): String = JSONObject()
|
||||
.put("goodsId", goodsId)
|
||||
.put("mappedColor", mappedColor)
|
||||
.put("mappedSize", mappedSize)
|
||||
.put("targetQuantity", quantity)
|
||||
.put("observedQuantity", observedQuantity ?: JSONObject.NULL)
|
||||
.put("selectedSummaryPresent", selectedSummaryPresent)
|
||||
.put("summaryPromptPresent", summaryPromptPresent)
|
||||
.put("unitPriceCent", unitPriceCent)
|
||||
.put("addressSuffix", addressSuffix)
|
||||
.put("activityName", activityName)
|
||||
.toString()
|
||||
}
|
||||
|
||||
internal fun observedPurchaseQuantity(snapshot: UiSnapshot): Long? = snapshot.nodes.filter {
|
||||
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
|
||||
}.singleOrNull()?.label?.toLongOrNull()
|
||||
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
|
||||
data class PurchaseOrderReadFailure(
|
||||
@@ -315,21 +337,20 @@ class PurchaseLiveAutomation(
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
|
||||
}
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
|
||||
if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) {
|
||||
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
|
||||
}
|
||||
rejectSubmitSelectionPrompt(submit.single())
|
||||
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
|
||||
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
|
||||
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
|
||||
val selection = PurchaseSelectionEvidence.observe(snapshot)
|
||||
return FinalConfirmationEvidence(
|
||||
input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty(),
|
||||
observedPurchaseQuantity(snapshot), selection.selectedSummaryPresent, selection.summaryPromptPresent,
|
||||
)
|
||||
}
|
||||
|
||||
fun submitOrderOnce() {
|
||||
@@ -340,8 +361,9 @@ class PurchaseLiveAutomation(
|
||||
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
|
||||
}
|
||||
rejectSubmitSelectionPrompt(targets.single())
|
||||
submitAttempted = true
|
||||
when (driver.clickFresh(targets.single())) {
|
||||
when (driver.clickFresh(targets.single().chosen)) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不唯一")
|
||||
else -> fail("PURCHASE_ORDER_RESULT_UNKNOWN", "创建订单点击结果不明确,请人工检查")
|
||||
@@ -882,7 +904,24 @@ class PurchaseLiveAutomation(
|
||||
* (Samsung: only a zero-size text node), fail explicitly (`bottom_row_unlabelled`) —
|
||||
* never fall back to a higher row.
|
||||
*/
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
private data class SubmitTarget(val row: SnapshotNode, val chosen: SnapshotNode, val text: String)
|
||||
|
||||
internal fun selectionPromptPresent(snapshot: UiSnapshot): Boolean =
|
||||
PurchaseSelectionEvidence.observe(snapshot).summaryPromptPresent ||
|
||||
finalSubmitTargets(snapshot).singleOrNull()?.let { isSelectionPrompt(it.text) } == true
|
||||
|
||||
private fun isSelectionPrompt(text: String): Boolean =
|
||||
Regex("^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$").matches(text.filterNot(Char::isWhitespace))
|
||||
|
||||
private fun rejectSubmitSelectionPrompt(target: SubmitTarget) {
|
||||
if (isSelectionPrompt(target.text)) {
|
||||
val detail = "finalSpec;stage=selection_prompt"
|
||||
panelDiagnostic(detail)
|
||||
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SubmitTarget> {
|
||||
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
fun clickableAncestor(node: SnapshotNode): SnapshotNode? {
|
||||
@@ -951,7 +990,14 @@ class PurchaseLiveAutomation(
|
||||
"leafClass=${chosen.className};rowClass=${row.className};w=${row.bounds.width};h=${row.bounds.height};" +
|
||||
"price=${hasPrice.diagFlag()};legacyMarker=${legacyMarker.diagFlag()};type=${screen.specPanelType}",
|
||||
)
|
||||
return listOf(chosen)
|
||||
// Prompt evidence uses each visible leaf's own label exactly once. A nested
|
||||
// clickable button is a separate action and cannot supply text for this row.
|
||||
val text = (listOf(row) + subtreeDescendants(row, snapshot)).filter { node ->
|
||||
node.visible && node.label.isNotBlank() && clickableAncestor(node)?.path == row.path &&
|
||||
subtreeDescendants(node, snapshot).none { it.label.isNotBlank() }
|
||||
}.sortedWith(compareBy<SnapshotNode> { it.bounds.top }.thenBy { it.bounds.left })
|
||||
.joinToString("") { it.label }
|
||||
return listOf(SubmitTarget(row, chosen, text))
|
||||
}
|
||||
|
||||
private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** In-memory stability evidence only. This is not proof of the page's goodsId. */
|
||||
internal data class ProductStabilityKey(val title: String?, val specEntry: Boolean, val quickEntry: Boolean, val panel: Boolean)
|
||||
|
||||
internal class PurchaseNavigationReadiness(
|
||||
private val startedAt: Long,
|
||||
private val path: PddLaunchPath?,
|
||||
private val acceptedAt: Long,
|
||||
) {
|
||||
private var browserObserved = false
|
||||
private var returnedAt: Long? = null
|
||||
private var stableSince: Long? = null
|
||||
private var stableKey: ProductStabilityKey? = null
|
||||
private var samples = 0
|
||||
private var lastClickAt: Long? = null
|
||||
|
||||
fun expired(now: Long): Boolean = now - startedAt !in 0 until TIMEOUT_MS
|
||||
|
||||
fun observe(now: Long, packageName: String?, key: ProductStabilityKey?): Boolean {
|
||||
if (expired(now)) return false
|
||||
if (isBrowser(packageName)) browserObserved = true
|
||||
if (packageName != PDD_PACKAGE) returnedAt = null
|
||||
val eligible = path == PddLaunchPath.DIRECT || (path == PddLaunchPath.BROWSER && browserObserved)
|
||||
if (!eligible || packageName != PDD_PACKAGE || key == null) {
|
||||
stableSince = null
|
||||
stableKey = null
|
||||
samples = 0
|
||||
return false
|
||||
}
|
||||
if (returnedAt == null) returnedAt = now
|
||||
if (stableKey != key || stableSince == null) {
|
||||
stableKey = key
|
||||
stableSince = now
|
||||
samples = 0
|
||||
}
|
||||
samples++
|
||||
val bufferStart = if (path == PddLaunchPath.DIRECT) acceptedAt else requireNotNull(returnedAt)
|
||||
return now - bufferStart >= BUFFER_MS && now - requireNotNull(stableSince) >= STABLE_MS && samples >= 3
|
||||
}
|
||||
|
||||
fun mayClickBrowser(now: Long, packageName: String?): Boolean =
|
||||
!expired(now) && path == PddLaunchPath.BROWSER && isBrowser(packageName) &&
|
||||
(lastClickAt == null || now - requireNotNull(lastClickAt) >= 1_000L)
|
||||
|
||||
fun clicked(now: Long) { lastClickAt = now }
|
||||
|
||||
companion object {
|
||||
const val TIMEOUT_MS = 30_000L
|
||||
const val BUFFER_MS = 3_000L
|
||||
const val STABLE_MS = 1_000L
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
fun isBrowser(packageName: String?): Boolean = packageName in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Service-instance memory only; never restored from results or the upload outbox. */
|
||||
internal class PurchaseProbeHandoff {
|
||||
internal data class Credential(
|
||||
val taskId: Long, val goodsId: String, val sourceAttemptId: String,
|
||||
val generation: Long, val completedAt: Long, val accessibility: Any, val foregroundRevision: Long,
|
||||
)
|
||||
internal data class Execution(val generation: Long, val previous: Credential?)
|
||||
private var generation = 0L
|
||||
private var credential: Credential? = null
|
||||
private var evaluatedGeneration = -1L
|
||||
private var invalidatedGeneration = -1L
|
||||
private var authorizedGeneration = -1L
|
||||
|
||||
@Synchronized fun beginExecution(): Execution {
|
||||
val previous = credential
|
||||
credential = null
|
||||
return Execution(++generation, previous)
|
||||
}
|
||||
|
||||
@Synchronized fun invalidate() {
|
||||
credential = null
|
||||
invalidatedGeneration = generation
|
||||
}
|
||||
|
||||
@Synchronized fun completeProbe(
|
||||
execution: Execution, taskId: Long, goodsId: String, attemptId: String,
|
||||
completedAt: Long, accessibility: Any, foregroundRevision: Long,
|
||||
successful: Boolean, safelyStored: Boolean, submitPending: Boolean, replayed: Boolean,
|
||||
) {
|
||||
if (execution.generation != generation || !successful || !safelyStored || !submitPending || replayed || attemptId.isBlank()) return
|
||||
credential = Credential(taskId, goodsId, attemptId, generation, completedAt, accessibility, foregroundRevision)
|
||||
}
|
||||
|
||||
@Synchronized fun eligible(
|
||||
execution: Execution, taskId: Long, goodsId: String, phase: String, attemptId: String,
|
||||
accessibility: Any, foregroundRevision: Long, now: Long,
|
||||
): Boolean {
|
||||
if (evaluatedGeneration == execution.generation) return false
|
||||
evaluatedGeneration = execution.generation
|
||||
val prior = execution.previous ?: return false
|
||||
val eligible = generation == execution.generation && invalidatedGeneration != execution.generation &&
|
||||
prior.generation + 1 == execution.generation && phase == "purchase" &&
|
||||
prior.taskId == taskId && prior.goodsId == goodsId && attemptId.isNotBlank() && prior.sourceAttemptId != attemptId &&
|
||||
prior.accessibility === accessibility && prior.foregroundRevision == foregroundRevision &&
|
||||
now - prior.completedAt in 0 until 30_000L
|
||||
if (eligible) authorizedGeneration = execution.generation
|
||||
return eligible
|
||||
}
|
||||
|
||||
/** Revalidate the already consumed credential after a potentially slow fresh capture; never creates another. */
|
||||
@Synchronized fun stillValid(execution: Execution, accessibility: Any?, foregroundRevision: Long, now: Long): Boolean {
|
||||
val prior = execution.previous ?: return false
|
||||
return authorizedGeneration == execution.generation && generation == execution.generation &&
|
||||
invalidatedGeneration != execution.generation && prior.accessibility === accessibility &&
|
||||
prior.foregroundRevision == foregroundRevision && now - prior.completedAt in 0 until 30_000L
|
||||
}
|
||||
}
|
||||
+56
-55
@@ -16,6 +16,9 @@ interface PurchaseUiDriver {
|
||||
result = clickFresh(target),
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
/** Recheck after fresh-node Binder reads and immediately before a browser navigation click. */
|
||||
fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome =
|
||||
if (allowed()) clickFreshDetailed(target) else FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
|
||||
/**
|
||||
* Reacquires one address-entry node by its accessibility path and validates
|
||||
* its immutable snapshot traits before clicking its nearest clickable
|
||||
@@ -85,6 +88,7 @@ data class PurchaseExecutionInput(
|
||||
val minUnitPriceCent: Long,
|
||||
val maxUnitPriceCent: Long,
|
||||
val addressSuffix: String = "",
|
||||
val reuseProbeProduct: Boolean = false,
|
||||
)
|
||||
|
||||
data class PurchaseExecutionOutcome(
|
||||
@@ -109,6 +113,9 @@ class PurchaseRehearsalExecutor(
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
private val probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
|
||||
private val acceptedLaunchPath: () -> PddLaunchPath? = { null },
|
||||
private val monotonicNow: () -> Long = { System.nanoTime() / 1_000_000L },
|
||||
private val confirmProbeHandoff: () -> Boolean = { true },
|
||||
) {
|
||||
private var purchasePanelContext: PurchasePanelContext? = null
|
||||
|
||||
@@ -121,12 +128,9 @@ class PurchaseRehearsalExecutor(
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||
for (action in rule.actions) {
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
// unrelated screen), so only skip navigation when a fresh snapshot
|
||||
// still carries safe PDD product/spec evidence.
|
||||
// Only the service's one-shot probe handoff permits reuse; fresh structure alone is insufficient.
|
||||
if (
|
||||
input.phase == "purchase" &&
|
||||
input.phase == "purchase" && input.reuseProbeProduct &&
|
||||
action.type == PurchaseActionType.OPEN_PRODUCT &&
|
||||
canReuseCurrentProduct(input)
|
||||
) continue
|
||||
@@ -205,7 +209,7 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
|
||||
currentScreen(input).let { screen ->
|
||||
screen.problem == null && screen.hasPurchaseProductEvidence()
|
||||
screen.problem == null && screen.hasPurchaseProductEvidence() && confirmProbeHandoff()
|
||||
}
|
||||
|
||||
private fun validateBeforeDeviceAction(
|
||||
@@ -260,57 +264,38 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
purchasePanelContext = null
|
||||
val startedAt = monotonicNow()
|
||||
if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
|
||||
val readiness = PurchaseNavigationReadiness(startedAt, acceptedLaunchPath(), monotonicNow())
|
||||
val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开")
|
||||
var clickAttempted = false
|
||||
var nextClickPoll = 0
|
||||
var lastClickReason = FreshClickReason.UNKNOWN
|
||||
var stableEvidenceReads = 0
|
||||
var pddForegroundObserved = false
|
||||
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
|
||||
repeat(300) {
|
||||
if (readiness.expired(monotonicNow())) return navigationTimeout()
|
||||
val snapshot = driver.capture()
|
||||
if (readiness.expired(monotonicNow())) return navigationTimeout()
|
||||
pageProblem(snapshot)?.let { return it }
|
||||
if (snapshot.packageName == PDD_PACKAGE) {
|
||||
pddForegroundObserved = true
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
|
||||
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
|
||||
pause(OPEN_PRODUCT_POLL_MILLIS)
|
||||
return@repeat
|
||||
}
|
||||
stableEvidenceReads = 0
|
||||
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
|
||||
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
|
||||
if (candidates.size == 1 && poll >= nextClickPoll) {
|
||||
clickAttempted = true
|
||||
val outcome = driver.clickFreshDetailed(candidates.single())
|
||||
lastClickReason = outcome.reason
|
||||
if (outcome.result == FreshActionResult.AMBIGUOUS) {
|
||||
return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
val key = if (screen.problem == null && screen.hasPurchaseProductEvidence()) ProductStabilityKey(
|
||||
screen.summary.title, screen.specEntry != null, screen.quickConfirmationEntry != null, screen.specPanelOpen,
|
||||
) else null
|
||||
if (readiness.observe(monotonicNow(), snapshot.packageName, key)) return null
|
||||
if (readiness.mayClickBrowser(monotonicNow(), snapshot.packageName)) {
|
||||
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
|
||||
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
|
||||
if (candidates.size == 1 && !readiness.expired(monotonicNow())) {
|
||||
val outcome = driver.clickNavigationFresh(candidates.single()) { !readiness.expired(monotonicNow()) }
|
||||
readiness.clicked(monotonicNow())
|
||||
if (readiness.expired(monotonicNow())) return navigationTimeout()
|
||||
if (outcome.result == FreshActionResult.AMBIGUOUS) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
|
||||
}
|
||||
// The browser can rerender while handing the URL to PDD, and
|
||||
// ACTION_CLICK may report false after the app transition has
|
||||
// already started. The observed PDD foreground is the success
|
||||
// condition; retry only this unique target after a short bound.
|
||||
nextClickPoll = poll + OPEN_PRODUCT_RETRY_POLLS
|
||||
}
|
||||
pause(OPEN_PRODUCT_POLL_MILLIS)
|
||||
if (readiness.expired(monotonicNow())) return navigationTimeout()
|
||||
pause(100)
|
||||
}
|
||||
if (pddForegroundObserved) {
|
||||
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
|
||||
}
|
||||
if (clickAttempted) {
|
||||
val message = when (lastClickReason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化"
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR -> "打开拼多多入口暂时无法点击"
|
||||
FreshClickReason.ACTION_CLICK_FALSE -> "打开拼多多后未进入商品页面"
|
||||
else -> "打开拼多多失败"
|
||||
}
|
||||
return failure("RULE_ACTION_FAILED", message)
|
||||
}
|
||||
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
|
||||
return navigationTimeout()
|
||||
}
|
||||
|
||||
private fun navigationTimeout() = failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
|
||||
|
||||
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
|
||||
var stableEvidenceReads = 0
|
||||
repeat(PRODUCT_PAGE_POLL_LIMIT) {
|
||||
@@ -787,6 +772,11 @@ class PurchaseRehearsalExecutor(
|
||||
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
|
||||
if (!summaryTokenMatched) {
|
||||
if (screen.selectedSummary == null && proofUsable) {
|
||||
if (candidates.any { it.available && it.node.visible &&
|
||||
SpecValueNormalizer.normalizeFinalConfirmation(it.text) == SpecValueNormalizer.normalizeFinalConfirmation(target) &&
|
||||
!it.node.selected && !it.node.checked }) {
|
||||
return FinalSpecVerification(false, "target_visible_unselected", false, candidates.size, true, true, matchingNodes.size)
|
||||
}
|
||||
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, true, matchingNodes.size)
|
||||
}
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
@@ -818,12 +808,15 @@ class PurchaseRehearsalExecutor(
|
||||
target: String,
|
||||
proof: ExactSpecSelectionProof?,
|
||||
): FinalSpecVerification {
|
||||
var screen = currentScreen(input)
|
||||
var selectionPromptPresent = false
|
||||
fun captureScreen() = currentScreen(input) { snapshot -> selectionPromptPresent = hasSelectionPrompt(snapshot) }
|
||||
var screen = captureScreen()
|
||||
screen.problem?.let {
|
||||
return FinalSpecVerification(false, it.code, false, 0, proof != null, false)
|
||||
}
|
||||
if (selectionPromptPresent) return FinalSpecVerification(false, "selection_prompt", false, 0, proof != null, false)
|
||||
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason in setOf("visible_selected_conflict", "selection_prompt", "target_visible_unselected")) return verification
|
||||
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
|
||||
|
||||
var signature = screen.dimensions.joinToString("|") { item ->
|
||||
@@ -847,7 +840,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
swipes++
|
||||
pause(300)
|
||||
screen = currentScreen(input)
|
||||
screen = captureScreen()
|
||||
screen.problem?.let {
|
||||
return verification.copy(
|
||||
reason = it.code,
|
||||
@@ -855,6 +848,9 @@ class PurchaseRehearsalExecutor(
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
}
|
||||
if (selectionPromptPresent) {
|
||||
return verification.copy(confirmed = false, reason = "selection_prompt", relocationAttempted = true, relocationSwipes = swipes)
|
||||
}
|
||||
if (!screen.specPanelOpen) {
|
||||
return verification.copy(
|
||||
reason = "relocation_panel_closed",
|
||||
@@ -866,7 +862,7 @@ class PurchaseRehearsalExecutor(
|
||||
relocationAttempted = true,
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason in setOf("visible_selected_conflict", "selection_prompt", "target_visible_unselected")) return verification
|
||||
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
}
|
||||
@@ -1144,6 +1140,9 @@ class PurchaseRehearsalExecutor(
|
||||
dimension to (normalizedTarget(dimension, rawTarget)
|
||||
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
|
||||
}
|
||||
if (selected.isEmpty() && hasSelectionPrompt(driver.capture())) {
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [reason=selection_prompt]")
|
||||
}
|
||||
selected.forEach { (dimension, target) ->
|
||||
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
|
||||
if (!verification.confirmed) {
|
||||
@@ -1167,6 +1166,9 @@ class PurchaseRehearsalExecutor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasSelectionPrompt(snapshot: UiSnapshot): Boolean =
|
||||
PurchaseLiveAutomation(driver, pause, panelDiagnostic).selectionPromptPresent(snapshot)
|
||||
|
||||
private fun hasAllRecordedSelectionProofs(
|
||||
input: PurchaseExecutionInput,
|
||||
proofs: Map<String, ExactSpecSelectionProof>,
|
||||
@@ -1234,8 +1236,9 @@ class PurchaseRehearsalExecutor(
|
||||
return normalized.takeIf(SpecValueNormalizer::isSafeSize)
|
||||
}
|
||||
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
private fun currentScreen(input: PurchaseExecutionInput, snapshotObserved: (UiSnapshot) -> Unit = {}): ParsedPddScreen {
|
||||
val snapshot = driver.capture()
|
||||
snapshotObserved(snapshot)
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
@@ -1267,10 +1270,8 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
companion object {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private const val OPEN_PRODUCT_POLL_LIMIT = 150
|
||||
private const val PRODUCT_PAGE_POLL_LIMIT = 150
|
||||
private const val PRODUCT_PAGE_STABLE_READS = 2
|
||||
private const val OPEN_PRODUCT_RETRY_POLLS = 10
|
||||
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
|
||||
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
|
||||
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Boolean-only summary observations within the #374 bounded quantity panel. */
|
||||
internal data class PurchaseSelectionEvidence(
|
||||
val selectedSummaryPresent: Boolean = false,
|
||||
val summaryPromptPresent: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
fun observe(snapshot: UiSnapshot): PurchaseSelectionEvidence {
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val original = PddScreenParser.parse(snapshot, config, "", null)
|
||||
val panel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
|
||||
?: return PurchaseSelectionEvidence()
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
fun within(node: SnapshotNode): Boolean {
|
||||
val visited = mutableSetOf<String>()
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && visited.add(current.path)) {
|
||||
if (current.path == panel.path) return true
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return false
|
||||
}
|
||||
fun contained(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
|
||||
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
|
||||
val nodes = snapshot.nodes.filter { (within(it) && contained(it)) ||
|
||||
(it.parentPath == null && it.label.isBlank() && !it.clickable) }
|
||||
val scoped = PddScreenParser.parse(snapshot.copy(nodes = nodes), config, "", null, forFinalConfirmation = true)
|
||||
if (!scoped.specPanelOpen) return PurchaseSelectionEvidence()
|
||||
val quantity = nodes.singleOrNull { it.visible && it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true }
|
||||
val firstOptionTop = scoped.dimensions.flatMap(VisibleDimension::values).minOfOrNull { it.node.bounds.top } ?: Int.MAX_VALUE
|
||||
val prompt = quantity != null && nodes.any { node ->
|
||||
node.visible && !node.clickable && node.className?.endsWith("TextView") == true &&
|
||||
node.bounds.bottom <= minOf(quantity.bounds.top, firstOptionTop) &&
|
||||
node.label.filterNot(Char::isWhitespace).let { it.startsWith("请选择") || it.startsWith("請選擇") }
|
||||
}
|
||||
val selected = scoped.finalSelectionSummaries.any { raw ->
|
||||
val compact = raw.filterNot(Char::isWhitespace)
|
||||
config.textAliases.selection.selectedPrefixes.any(compact::startsWith)
|
||||
}
|
||||
return PurchaseSelectionEvidence(selected, prompt)
|
||||
}
|
||||
|
||||
/** Retains #374's real ancestry and bounded full sheet, not the inner option list. */
|
||||
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
|
||||
val visible = snapshot.nodes.filter(SnapshotNode::visible)
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenArea = screenWidth.toLong() * screenHeight
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
var current = visible.singleOrNull {
|
||||
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
|
||||
} ?: return null
|
||||
var panel: SnapshotNode? = null
|
||||
val visited = mutableSetOf(current.path)
|
||||
while (true) {
|
||||
val parent = current.parentPath?.let(byPath::get) ?: break
|
||||
if (!visited.add(parent.path)) return null
|
||||
val area = parent.bounds.width.toLong() * parent.bounds.height
|
||||
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
|
||||
panel = parent
|
||||
current = parent
|
||||
}
|
||||
return panel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,20 @@ object SpecValueNormalizer {
|
||||
|
||||
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
|
||||
|
||||
/** Final purchase confirmation only: display equivalents, never semantic/content removal. */
|
||||
fun normalizeFinalConfirmation(value: String): String = buildString {
|
||||
value.forEach { ch ->
|
||||
append(when (ch) {
|
||||
'【', '〔', '[' -> '['
|
||||
'】', '〕', ']' -> ']'
|
||||
'(' -> '('
|
||||
')' -> ')'
|
||||
in 'A'..'Z', in 'a'..'z', in '0'..'9' -> (ch.code - 0xFEE0).toChar()
|
||||
else -> if (ch.isWhitespace()) ' ' else ch
|
||||
})
|
||||
}
|
||||
}.replace(Regex(" +"), " ").trim()
|
||||
|
||||
/** The same terminal-price grammar as normalization; never reads numbers inside a spec. */
|
||||
internal fun trailingPriceAmount(value: String): java.math.BigDecimal? =
|
||||
trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull()
|
||||
|
||||
@@ -103,6 +103,16 @@ data class PurchaseAgentTask(
|
||||
val attemptNumber: Int = 0,
|
||||
)
|
||||
|
||||
data class PurchaseResultAcknowledgement(val pending: Boolean, val replayed: Boolean) {
|
||||
companion object {
|
||||
fun from(data: JSONObject) = PurchaseResultAcknowledgement(
|
||||
pending = data.optString("status") == "pending",
|
||||
// The server deliberately omits replayed=false. A malformed explicit value is not a first response.
|
||||
replayed = data.has("replayed") && data.opt("replayed") != false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class CollectionHistoryItem(
|
||||
val taskId: Long,
|
||||
val attemptNumber: Int,
|
||||
@@ -527,9 +537,10 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data"))
|
||||
}
|
||||
|
||||
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String) {
|
||||
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String): PurchaseResultAcknowledgement {
|
||||
val payload = JSONObject(payloadJson)
|
||||
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token))
|
||||
val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data")
|
||||
return PurchaseResultAcknowledgement.from(data)
|
||||
}
|
||||
|
||||
fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> {
|
||||
|
||||
+14
-2
@@ -1,7 +1,7 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
internal object AgentDiagnosticSchema {
|
||||
const val VERSION = 4
|
||||
const val VERSION = 5
|
||||
|
||||
val failureSnapshotStatements = listOf(
|
||||
"""CREATE TABLE IF NOT EXISTS purchase_failure_snapshot (
|
||||
@@ -38,6 +38,12 @@ internal object AgentDiagnosticSchema {
|
||||
"horizontal_swipe_count" to "INTEGER",
|
||||
)
|
||||
|
||||
val colorDiscoveryOutcomeColumns = linkedMapOf(
|
||||
"color_vertical_swipe_count" to "INTEGER",
|
||||
"color_trailing_empty_read_count" to "INTEGER",
|
||||
"color_horizontal_incomplete" to "INTEGER",
|
||||
)
|
||||
|
||||
val createTableSql =
|
||||
"""CREATE TABLE agent_diagnostic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -66,6 +72,9 @@ internal object AgentDiagnosticSchema {
|
||||
initial_selected_size_count INTEGER,
|
||||
selected_summary_present INTEGER,
|
||||
horizontal_swipe_count INTEGER,
|
||||
color_vertical_swipe_count INTEGER,
|
||||
color_trailing_empty_read_count INTEGER,
|
||||
color_horizontal_incomplete INTEGER,
|
||||
task_type TEXT,
|
||||
task_attempt_id TEXT,
|
||||
device_id INTEGER,
|
||||
@@ -87,5 +96,8 @@ internal object AgentDiagnosticSchema {
|
||||
(if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) ->
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
} else emptyList()) +
|
||||
(if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList())
|
||||
(if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList()) +
|
||||
(if (oldVersion < 5 && newVersion >= 5) colorDiscoveryOutcomeColumns.mapNotNull { (name, definition) ->
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
} else emptyList())
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.database.sqlite.SQLiteOpenHelper
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
|
||||
enum class AgentDiagnosticStage {
|
||||
PRODUCT_NAVIGATION,
|
||||
SPEC_ENTRY_CLICK,
|
||||
SPEC_ENTRY_GESTURE,
|
||||
QUICK_CONFIRMATION_CLICK,
|
||||
@@ -24,6 +25,12 @@ enum class AgentDiagnosticStage {
|
||||
}
|
||||
|
||||
enum class AgentDiagnosticReason {
|
||||
NAV_DIRECT_ACCEPTED,
|
||||
NAV_DIRECT_REJECTED,
|
||||
NAV_DIRECT_EXCEPTION,
|
||||
NAV_BROWSER_ACCEPTED,
|
||||
NAV_BROWSER_REJECTED,
|
||||
NAV_BROWSER_EXCEPTION,
|
||||
SIZE_ADVICE_CLICK_BLOCKED,
|
||||
DETAIL_ENTRY_MATCHED,
|
||||
SPEC_PANEL_OPENED,
|
||||
@@ -114,6 +121,12 @@ data class AgentDiagnosticEvent(
|
||||
val deviceId: Long? = null,
|
||||
val phase: String? = null,
|
||||
val ruleSnapshotHash: String? = null,
|
||||
/** Successfully dispatched UP swipes during this color discovery; null means unknown. */
|
||||
val colorVerticalSwipeCount: Int? = null,
|
||||
/** Consecutive empty parsed-color discovery rounds at termination, excluding click/price reads. */
|
||||
val colorTrailingEmptyReadCount: Int? = null,
|
||||
/** Required horizontal exploration still unresolved at termination; not needed or recovered is false. */
|
||||
val colorHorizontalIncomplete: Boolean? = null,
|
||||
)
|
||||
|
||||
class SafeAgentDiagnosticRecorder(
|
||||
@@ -197,6 +210,9 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
|
||||
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
|
||||
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
|
||||
putNullableInt("color_vertical_swipe_count", event.colorVerticalSwipeCount)
|
||||
putNullableInt("color_trailing_empty_read_count", event.colorTrailingEmptyReadCount)
|
||||
putNullableBoolean("color_horizontal_incomplete", event.colorHorizontalIncomplete)
|
||||
put("agent_version", BuildConfig.VERSION_NAME)
|
||||
put("created_at", event.createdAt)
|
||||
}
|
||||
|
||||
+2
@@ -5,11 +5,13 @@ class PurchaseOutboxUploader(
|
||||
private val pending: () -> List<PendingPurchaseOutbox>,
|
||||
private val submit: (PendingPurchaseOutbox) -> Unit,
|
||||
private val markUploaded: (PendingPurchaseOutbox) -> Unit,
|
||||
private val afterUploaded: (PendingPurchaseOutbox) -> Unit = {},
|
||||
) {
|
||||
fun flush() {
|
||||
pending().forEach { item ->
|
||||
submit(item)
|
||||
markUploaded(item)
|
||||
afterUploaded(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-14
@@ -41,6 +41,11 @@ import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseProbeHandoff
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
import cn.ilapage.goauto.agent.network.PurchaseResultAcknowledgement
|
||||
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
@@ -91,6 +96,7 @@ class AgentForegroundService : Service() {
|
||||
private var repurchaseSession: RepurchaseSession? = null
|
||||
private val repurchaseEvidence = java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.ConcurrentHashMap<Long, RepurchaseExecutionEvidence>>()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val probeHandoff = PurchaseProbeHandoff()
|
||||
private val backfillGuard = OrderBackfillGuard(taskMutex)
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
private val working = AtomicBoolean(false)
|
||||
@@ -154,6 +160,7 @@ class AgentForegroundService : Service() {
|
||||
// Separate, serialized I/O ticks keep history reads and result observation off the
|
||||
// heartbeat scheduler and, especially, off the single purchase execution thread.
|
||||
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
|
||||
diagnosticInstance = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -197,6 +204,8 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
probeHandoff.invalidate()
|
||||
if (diagnosticInstance === this) diagnosticInstance = null
|
||||
repurchaseClosed.set(true)
|
||||
repurchase.requestStop(repurchase.state.roundId)
|
||||
repurchaseExecutor.shutdownNow()
|
||||
@@ -220,6 +229,30 @@ class AgentForegroundService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/** Shell-protected debug receiver only. Never claims/resets a server task or invokes a purchase executor. */
|
||||
internal fun runNavigationProbe(probe: (Context) -> Unit): Boolean {
|
||||
if (!BuildConfig.DEBUG) return false
|
||||
val reservation = Long.MAX_VALUE - 2
|
||||
val acquired = synchronized(taskMutex) {
|
||||
!repurchaseClosed.get() && repurchaseLocalIdle() && !repurchaseState.busy &&
|
||||
activeCollectionCooldown() == null && taskMutex.tryAcquire(reservation)
|
||||
}
|
||||
if (!acquired) return false
|
||||
probeHandoff.beginExecution()
|
||||
cancelIdleReturn("导航诊断")
|
||||
try {
|
||||
taskExecutor.execute {
|
||||
try { probe(this) }
|
||||
catch (error: Exception) { Log.w("GoAutoNavProbe", "probe_exception=${error.javaClass.name}") }
|
||||
finally { taskMutex.release(reservation) }
|
||||
}
|
||||
} catch (error: java.util.concurrent.RejectedExecutionException) {
|
||||
taskMutex.release(reservation)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun triggerSync() {
|
||||
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
|
||||
executor.execute {
|
||||
@@ -484,6 +517,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
try {
|
||||
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
|
||||
probeHandoff.beginExecution()
|
||||
val server = settingsStore.serverUrl()
|
||||
val credentials = identityStore.credentials() ?: error("设备尚未注册")
|
||||
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
|
||||
@@ -578,6 +612,7 @@ class AgentForegroundService : Service() {
|
||||
return
|
||||
}
|
||||
cancelIdleReturn("正在创建当前页面采集任务")
|
||||
probeHandoff.beginExecution()
|
||||
taskExecutor.execute {
|
||||
var taskId = 0L
|
||||
try {
|
||||
@@ -719,6 +754,12 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
|
||||
// Take and clear before claim/start or any eligibility checks. Every execution consumes the previous handoff.
|
||||
val handoffExecution = probeHandoff.beginExecution()
|
||||
var probeCompletedAt: Long? = null
|
||||
var probeAccessibility: GoAutoAccessibilityService? = null
|
||||
var probeForegroundRevision = -1L
|
||||
var acceptedPath: PddLaunchPath? = null
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
var resultSafelyStored = false
|
||||
@@ -783,8 +824,35 @@ class AgentForegroundService : Service() {
|
||||
} else {
|
||||
PurchaseRehearsalExecutor(
|
||||
driver = accessibility,
|
||||
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
|
||||
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) },
|
||||
openLink = { url ->
|
||||
probeHandoff.invalidate()
|
||||
acceptedPath = null
|
||||
val navigationStarted = SystemClock.elapsedRealtime()
|
||||
PddLinkLauncher(this).open(url, preferDirect = true) { event ->
|
||||
if (event.status == PddLaunchStatus.ACCEPTED) acceptedPath = event.path
|
||||
probeDiagnostic(AgentDiagnosticEvent(
|
||||
taskId = task.taskId,
|
||||
stage = AgentDiagnosticStage.PRODUCT_NAVIGATION,
|
||||
reason = AgentDiagnosticReason.valueOf("NAV_${event.path}_${event.status}"),
|
||||
elapsedMs = SystemClock.elapsedRealtime() - navigationStarted,
|
||||
targetClassName = event.exceptionClass,
|
||||
))
|
||||
}
|
||||
},
|
||||
acceptedLaunchPath = { acceptedPath },
|
||||
monotonicNow = SystemClock::elapsedRealtime,
|
||||
confirmProbeHandoff = {
|
||||
val current = GoAutoAccessibilityService.instance
|
||||
probeHandoff.stillValid(handoffExecution, current,
|
||||
current?.currentForegroundRevision() ?: -1L, SystemClock.elapsedRealtime())
|
||||
},
|
||||
probeSpecs = {
|
||||
collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic).also {
|
||||
probeCompletedAt = SystemClock.elapsedRealtime()
|
||||
probeAccessibility = accessibility
|
||||
probeForegroundRevision = accessibility.currentForegroundRevision()
|
||||
}
|
||||
},
|
||||
probeClickDiagnostic = { stage, target, outcome ->
|
||||
probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome))
|
||||
},
|
||||
@@ -796,15 +864,7 @@ class AgentForegroundService : Service() {
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
.put("goodsId", evidence.goodsId)
|
||||
.put("mappedColor", evidence.mappedColor)
|
||||
.put("mappedSize", evidence.mappedSize)
|
||||
.put("quantity", evidence.quantity)
|
||||
.put("unitPriceCent", evidence.unitPriceCent)
|
||||
.put("addressSuffix", evidence.addressSuffix)
|
||||
.put("activityName", evidence.activityName)
|
||||
.toString()
|
||||
val finalEvidence = evidence.toLocalJson()
|
||||
purchaseStore.markOrderSubmitStarted(task.taskId, task.taskAttemptId, boundaryRequestId, finalEvidence)
|
||||
api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token)
|
||||
},
|
||||
@@ -822,6 +882,10 @@ class AgentForegroundService : Service() {
|
||||
minUnitPriceCent = task.minUnitPriceCent,
|
||||
maxUnitPriceCent = task.maxUnitPriceCent,
|
||||
addressSuffix = task.addressSuffix,
|
||||
reuseProbeProduct = probeHandoff.eligible(
|
||||
handoffExecution, task.taskId, task.pddGoodsId, task.phase, task.taskAttemptId,
|
||||
accessibility, accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime(),
|
||||
),
|
||||
),
|
||||
parsedRule,
|
||||
PurchaseAgentCapabilities.supported,
|
||||
@@ -849,7 +913,22 @@ class AgentForegroundService : Service() {
|
||||
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
|
||||
}
|
||||
beginIdleReturnCooldown()
|
||||
flushPurchaseOutbox(api, token)
|
||||
// Only this first synchronous upload may establish a handoff. Outbox replays have no callback.
|
||||
flushPurchaseOutbox(api, token) { item, acknowledgement ->
|
||||
val completedAt = probeCompletedAt
|
||||
val accessibility = probeAccessibility
|
||||
if (item.requestId == requestId && item.attemptId == task.taskAttemptId && completedAt != null &&
|
||||
accessibility != null && GoAutoAccessibilityService.instance === accessibility &&
|
||||
accessibility.currentPackage() == "com.xunmeng.pinduoduo" &&
|
||||
accessibility.currentForegroundRevision() == probeForegroundRevision) {
|
||||
probeHandoff.completeProbe(
|
||||
handoffExecution, task.taskId, task.pddGoodsId, task.taskAttemptId, completedAt,
|
||||
accessibility, probeForegroundRevision,
|
||||
successful = task.phase == "spec_probe" && outcome.resultType == "spec_probe_completed",
|
||||
safelyStored = resultSafelyStored, submitPending = acknowledgement.pending, replayed = acknowledgement.replayed,
|
||||
)
|
||||
}
|
||||
}
|
||||
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
|
||||
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
|
||||
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
@@ -913,6 +992,7 @@ class AgentForegroundService : Service() {
|
||||
|
||||
private fun recoverInterruptedPurchases(api: AgentApiClient, token: String) {
|
||||
purchaseStore.interruptedAttempts().forEach { interrupted ->
|
||||
probeHandoff.beginExecution()
|
||||
if (interrupted.status == PurchaseTaskStore.STATUS_ORDER_SUBMIT_STARTED) {
|
||||
val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach
|
||||
try {
|
||||
@@ -964,11 +1044,20 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushPurchaseOutbox(api: AgentApiClient, token: String) {
|
||||
private fun flushPurchaseOutbox(
|
||||
api: AgentApiClient, token: String,
|
||||
onFirstSubmitted: (PendingPurchaseOutbox, PurchaseResultAcknowledgement) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var acknowledgement: PurchaseResultAcknowledgement? = null
|
||||
PurchaseOutboxUploader(
|
||||
pending = purchaseStore::pendingOutbox,
|
||||
submit = { item -> api.submitPurchaseResult(item.taskId, item.payloadJson, token) },
|
||||
submit = { item -> acknowledgement = api.submitPurchaseResult(item.taskId, item.payloadJson, token) },
|
||||
markUploaded = { item -> purchaseStore.markUploaded(item.id, item.taskId, item.attemptId) },
|
||||
afterUploaded = { item ->
|
||||
// A local upload-mark failure creates no credential; optional handoff inspection cannot fail the upload.
|
||||
runCatching { onFirstSubmitted(item, requireNotNull(acknowledgement)) }
|
||||
.onFailure { probeHandoff.invalidate(); logDiagnosticPersistenceFailure(it) }
|
||||
},
|
||||
).flush()
|
||||
runningTaskId.set(purchaseStore.activeTaskId())
|
||||
}
|
||||
@@ -1031,6 +1120,7 @@ class AgentForegroundService : Service() {
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
): TaskExecutionSummary {
|
||||
probeHandoff.beginExecution()
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
return try {
|
||||
@@ -1404,6 +1494,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun requestReturnToAgent(accessibility: GoAutoAccessibilityService) {
|
||||
probeHandoff.invalidate()
|
||||
val launched = accessibility.openAgentPreservingTab()
|
||||
if (launched) SystemClock.sleep(RETURN_CONFIRM_DELAY_MILLIS)
|
||||
val returned = launched && accessibility.currentPackage() == packageName
|
||||
@@ -1558,6 +1649,7 @@ class AgentForegroundService : Service() {
|
||||
* 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。
|
||||
*/
|
||||
private fun resetPddToHome(): PddLaunchResult {
|
||||
probeHandoff.invalidate()
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
|
||||
?: return PddLaunchResult.INTENT_MISSING
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
@@ -1572,6 +1664,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun launchPddToForeground(): PddLaunchResult {
|
||||
probeHandoff.invalidate()
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
|
||||
?: return PddLaunchResult.INTENT_MISSING
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
@@ -1586,6 +1679,8 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile internal var diagnosticInstance: AgentForegroundService? = null
|
||||
private set
|
||||
const val ACTION_REPURCHASE_PREPARE = "cn.ilapage.goauto.agent.REPURCHASE_PREPARE"
|
||||
const val ACTION_REPURCHASE_CONFIRM = "cn.ilapage.goauto.agent.REPURCHASE_CONFIRM"
|
||||
const val ACTION_REPURCHASE_STOP = "cn.ilapage.goauto.agent.REPURCHASE_STOP"
|
||||
|
||||
@@ -9,6 +9,27 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AgentDiagnosticRecorderTest {
|
||||
@Test
|
||||
fun colorDiscoveryOutcomeDefaultsToUnknownAndPreservesExplicitValues() {
|
||||
val event = AgentDiagnosticEvent(
|
||||
taskId = 370,
|
||||
stage = AgentDiagnosticStage.COLOR_DISCOVERY,
|
||||
reason = AgentDiagnosticReason.COLOR_EDGE_REACHED,
|
||||
)
|
||||
assertEquals(null, event.colorVerticalSwipeCount)
|
||||
assertEquals(null, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
val completed = event.copy(
|
||||
colorVerticalSwipeCount = 4,
|
||||
colorTrailingEmptyReadCount = 2,
|
||||
colorHorizontalIncomplete = false,
|
||||
)
|
||||
assertEquals(4, completed.colorVerticalSwipeCount)
|
||||
assertEquals(2, completed.colorTrailingEmptyReadCount)
|
||||
assertEquals(false, completed.colorHorizontalIncomplete)
|
||||
assertEquals(true, completed.copy(colorHorizontalIncomplete = true).colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() {
|
||||
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PddLaunchFallbackTest {
|
||||
@Test fun reportsDirectAcceptanceWithoutCallingBrowser() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { error("unexpected") }, events::add))
|
||||
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)), events)
|
||||
}
|
||||
@Test fun reportsOnlyExceptionClassThenBrowserAcceptance() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertTrue(PddLaunchFallback.open(true, { throw SecurityException("secret URL and address") }, { true }, events::add))
|
||||
assertEquals(listOf(
|
||||
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
|
||||
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.ACCEPTED),
|
||||
), events)
|
||||
assertFalse(events.toString().contains("secret"))
|
||||
}
|
||||
@Test fun reportsFalseAndBrowserExceptionWithoutChangingResult() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertFalse(PddLaunchFallback.open(true, { false }, { throw IllegalStateException("private") }, events::add))
|
||||
assertEquals(PddLaunchStatus.REJECTED, events[0].status)
|
||||
assertEquals(PddLaunchStatus.EXCEPTION, events[1].status)
|
||||
}
|
||||
@Test fun diagnosticFailureDoesNotCauseBrowserFallbackOrChangeResult() {
|
||||
var browserCalls = 0
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { browserCalls++; true }, { error("storage failed") }))
|
||||
assertEquals(0, browserCalls)
|
||||
}
|
||||
@Test fun browserOnlyReportsNoDirectAttempt() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertFalse(PddLaunchFallback.open(false, { error("unexpected") }, { false }, events::add))
|
||||
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED)), events)
|
||||
}
|
||||
@Test fun directSuccessDoesNotStartBrowser() {
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
|
||||
}
|
||||
|
||||
+248
-3
@@ -1220,6 +1220,246 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery counts only its own successful vertical swipes`() {
|
||||
val colors = listOf("A色", "B色", "C色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
|
||||
sizePages = listOf(listOf("S"), listOf("M"), listOf("L")),
|
||||
rowSize = 1,
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(2, event.colorVerticalSwipeCount)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.UP } > requireNotNull(event.colorVerticalSwipeCount))
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery failed vertical dispatch is not counted`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(colors = listOf("A色", "B色"), rowSize = 1, verticalSwipeSucceeds = false)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(AgentDiagnosticReason.COLOR_SWIPE_FAILED, event.reason)
|
||||
assertEquals(0, event.colorVerticalSwipeCount)
|
||||
assertEquals(0, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(false, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery counts empty parsed rounds after heading scrolls away without counting price reads`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
|
||||
rowSize = 1,
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
priceDelayReads = colors.associateWith { 2 },
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
// This fixture retains the options while their heading disappears. The
|
||||
// existing parser/traversal still misses the second page in this stage.
|
||||
assertEquals(colors.take(2), requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(2, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(2, event.colorVerticalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery initially empty is one empty round with unknown horizontal state`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = emptyList()), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(0, event.colorVerticalSwipeCount)
|
||||
assertEquals(1, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery resets trailing empty rounds after nonempty discovery`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorVerticalPages = listOf(colors.take(2), emptyList(), colors.takeLast(2)),
|
||||
rowSize = 1,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(3, event.colorVerticalSwipeCount)
|
||||
assertEquals(0, event.colorTrailingEmptyReadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery stable horizontal rows are complete`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(false, event.colorHorizontalIncomplete)
|
||||
assertEquals(0, event.colorTrailingEmptyReadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery horizontal dispatch failure before movement stays unknown`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val base = FakeCollectorDriver(colors = listOf("A色", "B色"))
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean =
|
||||
if (direction == SwipeDirection.LEFT) false else base.swipeSpec(direction, anchor)
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(0, event.horizontalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery successful horizontal dispatch alone does not prove required exploration`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
val limited = config().copy(limits = config().limits + mapOf("specHorizontalSwipes" to 1, "stableEdgeReads" to 2))
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule(limited))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(1, event.horizontalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery observed horizontal movement then failure is incomplete`() {
|
||||
val colors = listOf("A色", "B色", "C色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val base = FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2)))
|
||||
var leftSwipes = 0
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
|
||||
return base.swipeSpec(direction, anchor)
|
||||
}
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(1, event.horizontalSwipeCount)
|
||||
assertEquals(true, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery uncertain revisit preserves proven incomplete horizontal row`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色", "E色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val base = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorPages = listOf(listOf("A色", "B色", "D色", "E色"), listOf("B色", "C色", "D色", "E色")),
|
||||
rowSize = 2,
|
||||
resetHorizontalPageOnVerticalSwipe = true,
|
||||
)
|
||||
var leftSwipes = 0
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
|
||||
return base.swipeSpec(direction, anchor)
|
||||
}
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertTrue(requireNotNull(result.payload).dimensions.first { it.key == "color" }.values.contains("C色"))
|
||||
assertTrue(leftSwipes > 2)
|
||||
// The first A/B visit moves then fails; later A/B visits fail before
|
||||
// movement. Unknown new evidence must not erase that unresolved work.
|
||||
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery observed horizontal movement at budget is incomplete`() {
|
||||
val colors = listOf("A色", "B色", "C色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
val limited = config().copy(limits = config().limits + ("specHorizontalSwipes" to 1))
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
|
||||
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule(limited))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery disjoint horizontal reflow is unknown`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
|
||||
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(1, event.horizontalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery records only bounded aggregate evidence`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
@@ -1857,6 +2097,7 @@ class PddProductDetailCollectorTest {
|
||||
sizes: List<String> = listOf("S"),
|
||||
private val colorPages: List<List<String>> = listOf(colors),
|
||||
private val colorVerticalPages: List<List<String>>? = null,
|
||||
private val resetHorizontalPageOnVerticalSwipe: Boolean = false,
|
||||
private val sizePages: List<List<String>> = listOf(sizes),
|
||||
private val prices: Map<String, Long> = colors.associateWith { 1000L },
|
||||
private val failedClicks: Set<String> = emptySet(),
|
||||
@@ -1867,6 +2108,7 @@ class PddProductDetailCollectorTest {
|
||||
private val rowSize: Int = 4,
|
||||
private val hideSelectedColorOption: Boolean = false,
|
||||
private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false,
|
||||
private val hideColorHeadingAfterFirstVerticalPage: Boolean = false,
|
||||
private val soldOutUntilPulls: Int = 0,
|
||||
private val startWithPanelClosed: Boolean = false,
|
||||
private val firstEntryOpensReview: Boolean = false,
|
||||
@@ -1986,7 +2228,7 @@ class PddProductDetailCollectorTest {
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage && !panelCollapsedNow) {
|
||||
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
@@ -2118,8 +2360,11 @@ class PddProductDetailCollectorTest {
|
||||
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
|
||||
when (direction) {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
SwipeDirection.UP -> {
|
||||
verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
if (resetHorizontalPageOnVerticalSwipe) horizontalPage = 0
|
||||
}
|
||||
SwipeDirection.DOWN -> {
|
||||
verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
downSwipeCount++
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.json.JSONObject
|
||||
|
||||
/** #376 replaces #374's final value-proof contract. All fixtures are synthetic. */
|
||||
class PurchaseFinalSpecConfirmationTest {
|
||||
@Test fun `local final evidence distinguishes target observed quantity and scoped summary flags`() {
|
||||
val json = JSONObject(check(sheet("已选 黑色 XL"), input().copy(quantity = 2)).toLocalJson())
|
||||
assertFalse(json.has("quantity"))
|
||||
assertEquals(2, json.getLong("targetQuantity"))
|
||||
assertEquals(1, json.getLong("observedQuantity"))
|
||||
assertTrue(json.getBoolean("selectedSummaryPresent"))
|
||||
assertFalse(json.getBoolean("summaryPromptPresent"))
|
||||
val prompt = check(sheet("请 选择:颜色"), input())
|
||||
assertFalse(prompt.selectedSummaryPresent)
|
||||
assertTrue(prompt.summaryPromptPresent)
|
||||
val external = sheet(null).addText("r/outside", "已选 黑色 XL", NodeBounds(20, 400, 800, 460))
|
||||
assertFalse(check(external, input()).selectedSummaryPresent)
|
||||
assertFalse(check(sheet("#黑色 XL#"), input()).selectedSummaryPresent)
|
||||
}
|
||||
|
||||
@Test fun `observed quantity is nullable for missing or multiple numeric editors`() {
|
||||
val single = sheet(null)
|
||||
assertEquals(1L, observedPurchaseQuantity(single))
|
||||
assertNull(observedPurchaseQuantity(single.mapNodes { if (it.className == "android.widget.EditText") it.copy(visible = false) else it }))
|
||||
val quantity = single.nodes.single { it.className == "android.widget.EditText" }
|
||||
assertNull(observedPurchaseQuantity(single.copy(nodes = single.nodes + quantity.copy(path = "r/second"))))
|
||||
val evidence = check(single, input()).copy(observedQuantity = null)
|
||||
val json = JSONObject(evidence.toLocalJson())
|
||||
assertTrue(json.has("observedQuantity"))
|
||||
assertTrue(json.isNull("observedQuantity"))
|
||||
}
|
||||
|
||||
@Test fun `summary prompt is scoped to visible nonclickable header rather than option or external text`() {
|
||||
val normal = sheet(null)
|
||||
val driver = object : PurchaseUiDriver {
|
||||
override fun capture() = normal
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("unexpected click")
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
}
|
||||
val live = PurchaseLiveAutomation(driver, pause = {})
|
||||
for (prompt in listOf("请选择颜色", "请 选择:颜色", "請選擇: 顏色")) {
|
||||
assertTrue(live.selectionPromptPresent(sheet(prompt)))
|
||||
}
|
||||
val summaryBounds = NodeBounds(384, 791, 1068, 852)
|
||||
for ((index, snapshot) in listOf(
|
||||
normal.addText("r/external", "请选择颜色", summaryBounds),
|
||||
normal.addText("r/sheet/fake", "请选择颜色", summaryBounds).mapNodes { if (it.path.endsWith("fake")) it.copy(parentPath = "r") else it },
|
||||
sheet("请选择颜色").mapNodes { if (it.path.endsWith("body/summary")) it.copy(visible = false) else it },
|
||||
sheet("请选择颜色").mapNodes { if (it.path.endsWith("body/summary")) it.copy(clickable = true) else it },
|
||||
normal.addText("r/sheet/body/list/color/o0/prompt", "请选择颜色", NodeBounds(40, 1600, 300, 1640)),
|
||||
normal.mapNodes { if (it.path.startsWith("r/sheet/body/qty")) it.copy(bounds = it.bounds.copy(top = 1990, bottom = 2020)) else it }
|
||||
.addText("r/sheet/body/list/color/o0/prompt", "请选择颜色", NodeBounds(40, 1600, 300, 1640)),
|
||||
sheet("请选择颜色").mapNodes { if (it.path in listOf("r/sheet", "r/sheet/body")) it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it },
|
||||
).withIndex()) {
|
||||
assertFalse("variant=$index", live.selectionPromptPresent(snapshot))
|
||||
assertFalse(PurchaseSelectionEvidence.observe(snapshot).summaryPromptPresent)
|
||||
}
|
||||
// A missing final button never adds a pre-address failure by itself.
|
||||
assertFalse(live.selectionPromptPresent(normal.mapNodes { if (it.path.startsWith("r/sheet/submit")) it.copy(visible = false) else it }))
|
||||
}
|
||||
@Test fun `normal button permits missing heading marketing summary and offscreen size`() {
|
||||
accepts(sheet("#示例活动#" ).mapNodes { if (it.path.endsWith("color/h")) it.copy(visible = false) else it })
|
||||
accepts(sheet("#示例活动#").mapNodes { if (it.path.contains("list/size")) it.copy(visible = false) else it })
|
||||
}
|
||||
|
||||
@Test fun `normal button permits wrong selected values unavailable targets and collisions`() {
|
||||
accepts(sheet(null).mapNodes {
|
||||
when {
|
||||
it.path.endsWith("color/o0") -> it.copy(selected = true, contentDescription = "白色")
|
||||
it.path.endsWith("size/o0/t") -> it.copy(selected = true, text = "L")
|
||||
else -> it
|
||||
}
|
||||
})
|
||||
accepts(sheet("已选 白色 L").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
|
||||
accepts(sheet(null).let { it.copy(nodes = it.nodes + it.nodes.filter { n -> n.path.contains("color/o0") }.map { n ->
|
||||
n.copy(path = n.path.replace("o0", "o1"), parentPath = n.parentPath?.replace("o0", "o1"), bounds = n.bounds.copy(left = 400, right = 700))
|
||||
}) })
|
||||
}
|
||||
|
||||
@Test fun `normal button permits mismatched quantity and summary prompt`() {
|
||||
accepts(sheet("请选择:颜色分类 尺码"), input().copy(quantity = 2))
|
||||
accepts(sheet("請選擇: 顏色 尺碼"))
|
||||
}
|
||||
|
||||
@Test fun `special button rejects simplified traditional whitespace and split prompt`() {
|
||||
for (prompt in listOf("选择颜色分类及尺码后,提交订单", "選擇顏色及尺碼後,提交訂單", " 选 择 颜色 后 , 提交 订单 ")) {
|
||||
rejects(sheet("已选 黑色 XL").button(prompt), "PURCHASE_SPEC_NOT_MATCHED")
|
||||
}
|
||||
rejects(splitButton(), "PURCHASE_SPEC_NOT_MATCHED")
|
||||
rejects(splitButton().mapNodes { if (it.path == "r/sheet/submit/l") it.copy(text = "选择颜色后,提交订单") else it }, "PURCHASE_SPEC_NOT_MATCHED")
|
||||
}
|
||||
|
||||
@Test fun `whole match ignores prefix suffix hidden external and independent child fragments`() {
|
||||
for (label in listOf("提示选择颜色后,提交订单", "选择颜色后,提交订单优惠", "选择颜色提交订单", "提交订单")) accepts(sheet(null).button(label))
|
||||
accepts(sheet(null).addText("r/outside", "选择颜色后,提交订单", NodeBounds(20, 10, 800, 70)))
|
||||
accepts(sheet(null).addText("r/sheet/submit/hidden", "选择颜色后,提交订单", NodeBounds(20, 2140, 800, 2170)).mapNodes {
|
||||
if (it.path.endsWith("hidden")) it.copy(visible = false) else it
|
||||
})
|
||||
accepts(sheet(null).mapNodes { when (it.path) {
|
||||
"r/sheet/submit" -> it.copy(text = "选择颜色后,提交订单")
|
||||
"r/sheet/submit/l/t" -> it.copy(text = "选择颜色后,提交订单", visible = false)
|
||||
else -> it
|
||||
} }) // Do not use a parent's aggregate label when its labelled child is hidden.
|
||||
accepts(splitButton().mapNodes { if (it.path.endsWith("/first")) it.copy(clickable = true) else it })
|
||||
accepts(splitButton().mapNodes { if (it.path.endsWith("/first")) it.copy(path = "r/outside", parentPath = "r") else it })
|
||||
}
|
||||
|
||||
@Test fun `address price and missing button still reject without actions`() {
|
||||
rejects(sheet(null), "PURCHASE_PRICE_OUT_OF_RANGE", input().copy(maxUnitPriceCent = 1))
|
||||
rejects(sheet(null).mapNodes { if (it.path.endsWith("addr/a/detail")) it.copy(text = "示例地址") else it }, "PURCHASE_ADDRESS_UPDATE_FAILED")
|
||||
rejects(sheet(null).mapNodes { if (it.path.startsWith("r/sheet/submit")) it.copy(visible = false) else it }, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS")
|
||||
}
|
||||
|
||||
@Test fun `collector opt in retains independent candidates without changing regular parsing`() {
|
||||
val snapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色", "黑色")))
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val regular = PddScreenParser.parse(snapshot, config, "376", null)
|
||||
val final = PddScreenParser.parse(snapshot, config, "376", null, forFinalConfirmation = true)
|
||||
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(regular.selectedSummary, final.selectedSummary)
|
||||
}
|
||||
|
||||
private fun splitButton(): UiSnapshot = sheet(null).button("提交订单").mapNodes {
|
||||
if (it.path.endsWith("submit/l/t")) it.copy(bounds = NodeBounds(600, 2180, 1000, 2216)) else it
|
||||
}.addText("r/sheet/submit/l/first", "选择颜色后,", NodeBounds(20, 2180, 590, 2216))
|
||||
|
||||
private fun sheet(summary: String?): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色"), sizeLabels = listOf("XL")))
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
|
||||
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
|
||||
"r/sheet/submit/l/t" -> it.copy(text = "提交订单")
|
||||
else -> it
|
||||
} }
|
||||
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
|
||||
private fun UiSnapshot.button(label: String) = mapNodes { if (it.path.endsWith("submit/l/t")) it.copy(text = label) else it }
|
||||
private fun UiSnapshot.addText(path: String, text: String, bounds: NodeBounds) = copy(nodes = nodes + SnapshotNode(
|
||||
path, path.substringBeforeLast('/'), text, null, null, "android.widget.TextView", bounds,
|
||||
false, false, false, false, true, true,
|
||||
))
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 376, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=376",
|
||||
goodsId = "376", mappedColor = "黑色", mappedSize = "XL", quantity = 1,
|
||||
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg376",
|
||||
)
|
||||
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
|
||||
val driver = object : PurchaseUiDriver {
|
||||
override fun capture() = snapshot
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("unexpected click")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
}
|
||||
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
|
||||
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg376"))
|
||||
}
|
||||
private fun accepts(snapshot: UiSnapshot, input: PurchaseExecutionInput = input()) {
|
||||
assertEquals(input.mappedColor, check(snapshot, input).mappedColor)
|
||||
}
|
||||
private fun rejects(snapshot: UiSnapshot, expectedCode: String, input: PurchaseExecutionInput = input()) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
try {
|
||||
check(snapshot, input, diagnostics)
|
||||
fail("expected $expectedCode")
|
||||
} catch (failure: PurchaseLiveException) {
|
||||
assertEquals(expectedCode, failure.code)
|
||||
if (expectedCode == "PURCHASE_SPEC_NOT_MATCHED") {
|
||||
assertEquals("finalSpec;stage=selection_prompt", diagnostics.last())
|
||||
assertTrue(failure.message.orEmpty().contains(diagnostics.last()))
|
||||
assertFalse(failure.message.orEmpty().contains(ADDRESS))
|
||||
}
|
||||
}
|
||||
}
|
||||
private companion object { const val ADDRESS = "示例区示例路376号_cg376" }
|
||||
}
|
||||
@@ -8,6 +8,9 @@ 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.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
@@ -18,6 +21,105 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `pre address summary or button selection prompt stops executor before any address action`() {
|
||||
for (variant in listOf("summary", "button", "second-dimension", "after-swipe")) {
|
||||
val result = runPromptBoundary(prePrompt = variant)
|
||||
assertEquals(variant, "failed", result.outcome.resultType)
|
||||
assertEquals(variant, "PURCHASE_SPEC_SELECTION_UNCONFIRMED", result.outcome.errorCode)
|
||||
assertTrue(result.outcome.message, result.outcome.message.contains("reason=selection_prompt"))
|
||||
assertEquals(variant, 0, result.boundaries)
|
||||
assertEquals(variant, 0, result.driver.addressPathClicks)
|
||||
assertEquals(variant, 0, result.driver.inputCount)
|
||||
assertEquals(variant, 0, result.driver.submitClicks)
|
||||
assertEquals(variant, if (variant == "after-swipe") 1 else 0, result.preSwipes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final selection prompt is ordinary failure before boundary and unknown after boundary with zero clicks`() {
|
||||
val before = runPromptBoundary(finalPrompt = true)
|
||||
assertEquals("failed", before.outcome.resultType)
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", before.outcome.errorCode)
|
||||
assertTrue(before.outcome.message.contains("finalSpec;stage=selection_prompt"))
|
||||
assertEquals(0, before.boundaries)
|
||||
assertEquals(0, before.driver.submitClicks)
|
||||
assertEquals(1, before.driver.inputCount)
|
||||
val after = runPromptBoundary(boundaryPrompt = true)
|
||||
assertEquals("order_result_unknown", after.outcome.resultType)
|
||||
assertEquals("PURCHASE_ORDER_RESULT_UNKNOWN", after.outcome.errorCode)
|
||||
assertEquals(1, after.boundaries)
|
||||
assertEquals(0, after.driver.submitClicks)
|
||||
assertEquals(0, after.driver.scopedSwipes + after.driver.genericSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `post address wrong spec and quantity normal button complete executor once`() {
|
||||
val result = runPromptBoundary(wrongFinalValues = true)
|
||||
assertEquals("order_created", result.outcome.resultType)
|
||||
assertEquals(1, result.boundaries)
|
||||
assertEquals(1, result.driver.submitClicks)
|
||||
}
|
||||
|
||||
private data class PromptBoundaryResult(val outcome: PurchaseExecutionOutcome, val driver: LiveDriver, val boundaries: Int, val preSwipes: Int)
|
||||
|
||||
private fun runPromptBoundary(
|
||||
prePrompt: String? = null, finalPrompt: Boolean = false, boundaryPrompt: Boolean = false, wrongFinalValues: Boolean = false,
|
||||
): PromptBoundaryResult {
|
||||
val addressDriver = LiveDriver()
|
||||
var step = ""
|
||||
var boundaries = 0
|
||||
var verifyCaptures = 0
|
||||
var preSwipes = 0
|
||||
val selectedSheet = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色"), sizeLabels = listOf("XL"))).let { sheet ->
|
||||
sheet.copy(nodes = sheet.nodes.map { when {
|
||||
it.path.endsWith("body/summary") -> it.copy(text = "已选 黑色 XL")
|
||||
it.path.endsWith("qty/input") -> it.copy(text = "2")
|
||||
it.path.endsWith("submit/l/t") -> it.copy(text = "提交订单")
|
||||
it.path.endsWith("color/o0") || it.path.endsWith("size/o0/t") -> it.copy(selected = true)
|
||||
else -> it
|
||||
} })
|
||||
}
|
||||
val driver = object : PurchaseUiDriver by addressDriver {
|
||||
override fun capture(): UiSnapshot {
|
||||
if (step in listOf("updateShippingAddress", "createOrder", "readOrderResult")) {
|
||||
val snapshot = addressDriver.capture()
|
||||
if (step != "createOrder") return snapshot
|
||||
return snapshot.copy(nodes = snapshot.nodes.map { node -> when {
|
||||
node.path == "panel/submit" && (finalPrompt || (boundaryPrompt && boundaries > 0)) ->
|
||||
node.copy(text = "选择颜色分类及尺码后,提交订单")
|
||||
wrongFinalValues && node.path == "panel/selected" -> node.copy(text = "已选 白色 L")
|
||||
wrongFinalValues && node.path == "panel/quantity" -> node.copy(text = "9")
|
||||
else -> node
|
||||
} })
|
||||
}
|
||||
if (step != "verifyOrderSummary") return selectedSheet
|
||||
verifyCaptures++
|
||||
return selectedSheet.copy(nodes = selectedSheet.nodes.map { node -> when {
|
||||
prePrompt == "after-swipe" && node.path.contains("list/color") -> node.copy(visible = false)
|
||||
prePrompt == "after-swipe" && preSwipes == 0 && node.path.endsWith("body/summary") -> node.copy(text = "已选 白色 XL")
|
||||
(prePrompt == "summary" || (prePrompt == "after-swipe" && preSwipes > 0)) && node.path.endsWith("body/summary") -> node.copy(text = "请选择:颜色分类")
|
||||
(prePrompt == "button" || (prePrompt == "second-dimension" && verifyCaptures > 1)) && node.path.endsWith("submit/l/t") ->
|
||||
node.copy(text = "选择颜色分类及尺码后,提交订单")
|
||||
else -> node
|
||||
} })
|
||||
}
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
if (step == "verifyOrderSummary") { preSwipes++; return true }
|
||||
return addressDriver.swipePurchaseIn(target, direction, durationMs)
|
||||
}
|
||||
}
|
||||
val rule = PurchaseRuleParser.parse("""{
|
||||
"schemaVersion":1,"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1"],
|
||||
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]
|
||||
}""")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { error("unexpected open") }, { null }, pause = {},
|
||||
stepChanged = { step = it }, beforeOrderSubmit = { boundaries++ },
|
||||
).execute(input().copy(reuseProbeProduct = true), rule, PurchaseAgentCapabilities.supported)
|
||||
return PromptBoundaryResult(outcome, addressDriver, boundaries, preSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hint delayed address update preserves fresh payment back budget for late order evidence`() {
|
||||
val observationDriver = OrderObservationDriver { sample ->
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseNavigationReadinessTest {
|
||||
private val pdd = PurchaseNavigationReadiness.PDD_PACKAGE
|
||||
private val browser = "com.heytap.browser"
|
||||
private val old = ProductStabilityKey("old", true, false, false)
|
||||
private val fresh = old.copy(title = "new")
|
||||
|
||||
@Test fun `diag3 browser route ignores old product before browser then buffers returned pdd`() {
|
||||
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
|
||||
for (now in listOf(921L, 1_000L, 1_738L)) assertFalse(policy.observe(now, pdd, old))
|
||||
assertFalse(policy.observe(1_739, browser, null))
|
||||
assertFalse(policy.observe(12_189, pdd, old))
|
||||
assertFalse(policy.observe(13_000, pdd, old))
|
||||
assertFalse(policy.observe(13_218, pdd, fresh))
|
||||
assertFalse(policy.observe(14_218, pdd, fresh))
|
||||
assertFalse(policy.observe(15_188, pdd, fresh))
|
||||
assertTrue(policy.observe(15_189, pdd, fresh))
|
||||
}
|
||||
|
||||
@Test fun `stable initial product cannot pass browser route even after buffer`() {
|
||||
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
|
||||
for (now in 0L..30_000L step 100) assertFalse(policy.observe(now, pdd, old))
|
||||
assertTrue(policy.expired(30_000))
|
||||
}
|
||||
|
||||
@Test fun `direct route requires buffer plus three stable samples without an empty frame`() {
|
||||
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 500)
|
||||
assertFalse(policy.observe(500, pdd, old))
|
||||
assertFalse(policy.observe(2_500, pdd, old))
|
||||
assertFalse(policy.observe(3_499, pdd, old))
|
||||
assertTrue(policy.observe(3_500, pdd, old))
|
||||
val three = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 0)
|
||||
assertFalse(three.observe(3_000, pdd, old))
|
||||
assertFalse(three.observe(4_000, pdd, old))
|
||||
assertTrue(three.observe(4_001, pdd, old))
|
||||
}
|
||||
|
||||
@Test fun `stability resets on package root missing evidence title and structure changes`() {
|
||||
for ((pkg, key) in listOf(browser to null, pdd to null, pdd to fresh, pdd to old.copy(panel = true))) {
|
||||
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 0)
|
||||
policy.observe(2_000, pdd, old)
|
||||
policy.observe(2_500, pdd, old)
|
||||
assertFalse(policy.observe(2_999, pkg, key))
|
||||
assertFalse(policy.observe(3_000, pdd, old))
|
||||
assertFalse(policy.observe(3_999, pdd, old))
|
||||
assertTrue(policy.observe(4_000, pdd, old))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `unknown path never passes and only approved browser route clicks at least one second apart`() {
|
||||
val unknown = PurchaseNavigationReadiness(0, null, 0)
|
||||
assertFalse(unknown.observe(4_000, pdd, old))
|
||||
assertFalse(unknown.mayClickBrowser(4_000, browser))
|
||||
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
|
||||
for (pkg in listOf(pdd, "cn.ilapage.goauto.agent", "other.browser")) assertFalse(policy.mayClickBrowser(0, pkg))
|
||||
for (pkg in listOf(browser, "com.android.chrome", "com.android.browser")) assertTrue(policy.mayClickBrowser(0, pkg))
|
||||
policy.clicked(0)
|
||||
assertFalse(policy.mayClickBrowser(999, browser))
|
||||
assertTrue(policy.mayClickBrowser(1_000, browser))
|
||||
assertFalse(policy.mayClickBrowser(30_000, browser))
|
||||
}
|
||||
|
||||
@Test fun `deadline includes launch time and never accepts at thirty seconds`() {
|
||||
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 29_000)
|
||||
assertFalse(policy.observe(29_000, pdd, old))
|
||||
assertFalse(policy.observe(29_500, pdd, old))
|
||||
assertFalse(policy.observe(30_000, pdd, old))
|
||||
assertTrue(policy.expired(-1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseOutboxHandoffTest {
|
||||
private val item = PendingPurchaseOutbox(1, 42, "probe", "request", "{}")
|
||||
|
||||
@Test fun `handoff callback runs only after successful submit and local upload mark`() {
|
||||
for (failure in listOf("submit", "mark", "none")) {
|
||||
val events = mutableListOf<String>()
|
||||
val uploader = PurchaseOutboxUploader(
|
||||
pending = { listOf(item) },
|
||||
submit = { events += "submit"; if (failure == "submit") error("offline") },
|
||||
markUploaded = { events += "mark"; if (failure == "mark") error("disk") },
|
||||
afterUploaded = { events += "handoff" },
|
||||
)
|
||||
runCatching(uploader::flush)
|
||||
assertEquals(when (failure) {
|
||||
"submit" -> listOf("submit")
|
||||
"mark" -> listOf("submit", "mark")
|
||||
else -> listOf("submit", "mark", "handoff")
|
||||
}, events)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `ordinary replay uploader has no handoff callback`() {
|
||||
var submitted = 0
|
||||
PurchaseOutboxUploader({ listOf(item) }, { submitted++ }, {}).flush()
|
||||
assertEquals(1, submitted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseProbeHandoff
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseProbeHandoffTest {
|
||||
@Test fun `post capture confirmation rejects expiry foreground change recreation and later execution`() {
|
||||
val state = seeded()
|
||||
val execution = state.beginExecution()
|
||||
assertTrue(state.eligible(execution, 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
assertTrue(state.stillValid(execution, accessibility, 7, 30_999))
|
||||
assertFalse(state.stillValid(execution, accessibility, 7, 31_000))
|
||||
assertFalse(state.stillValid(execution, accessibility, 8, 1_001))
|
||||
assertFalse(state.stillValid(execution, Any(), 7, 1_001))
|
||||
state.beginExecution()
|
||||
assertFalse(state.stillValid(execution, accessibility, 7, 1_001))
|
||||
}
|
||||
|
||||
private val accessibility = Any()
|
||||
private fun seeded(age: Long = 0, success: Boolean = true, persisted: Boolean = true, pending: Boolean = true, replayed: Boolean = false): PurchaseProbeHandoff {
|
||||
return PurchaseProbeHandoff().also { state ->
|
||||
val execution = state.beginExecution()
|
||||
state.completeProbe(execution, 42, "123", "probe-attempt", 1_000 - age, accessibility, 7,
|
||||
successful = success, safelyStored = persisted, submitPending = pending, replayed = replayed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `handoff age is completion based exclusive thirty seconds`() {
|
||||
for (age in listOf(0L, 29_999L, 30_000L, -1L)) {
|
||||
val state = seeded(age)
|
||||
assertEquals(age in 0..29_999, state.eligible(state.beginExecution(), 42, "123", "purchase", "purchase-attempt", accessibility, 7, 1_000))
|
||||
}
|
||||
}
|
||||
@Test fun `handoff is consumed before all eligibility checks`() {
|
||||
val state = seeded()
|
||||
assertFalse(state.eligible(state.beginExecution(), 43, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
}
|
||||
@Test fun `different source and purchase attempts are normal but source replay cannot reuse`() {
|
||||
val state = seeded()
|
||||
assertTrue(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
val replay = seeded()
|
||||
assertFalse(replay.eligible(replay.beginExecution(), 42, "123", "purchase", "probe-attempt", accessibility, 7, 1_000))
|
||||
}
|
||||
@Test fun `failed persistence failed matching and replayed uploads create no credential`() {
|
||||
for (state in listOf(seeded(success = false), seeded(persisted = false), seeded(pending = false), seeded(replayed = true))) {
|
||||
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
}
|
||||
}
|
||||
@Test fun `interleaving generation accessibility foreground service and explicit navigation invalidate`() {
|
||||
val interleaved = seeded()
|
||||
val ticket = interleaved.beginExecution()
|
||||
interleaved.beginExecution()
|
||||
assertFalse(interleaved.eligible(ticket, 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
val replaced = seeded()
|
||||
assertFalse(replaced.eligible(replaced.beginExecution(), 42, "123", "purchase", "next", Any(), 7, 1_000))
|
||||
val foreground = seeded()
|
||||
assertFalse(foreground.eligible(foreground.beginExecution(), 42, "123", "purchase", "next", accessibility, 8, 1_000))
|
||||
val freshService = PurchaseProbeHandoff()
|
||||
assertFalse(freshService.eligible(freshService.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
val navigated = seeded()
|
||||
navigated.invalidate()
|
||||
assertFalse(navigated.eligible(navigated.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
}
|
||||
@Test fun `wrong goods or phase consumes and delayed completion cannot overwrite later execution`() {
|
||||
for ((goods, phase) in listOf("456" to "purchase", "123" to "spec_probe", "123" to "collection")) {
|
||||
val state = seeded()
|
||||
assertFalse(state.eligible(state.beginExecution(), 42, goods, phase, "next", accessibility, 7, 1_000))
|
||||
}
|
||||
val state = PurchaseProbeHandoff()
|
||||
val old = state.beginExecution()
|
||||
state.beginExecution()
|
||||
state.completeProbe(old, 42, "123", "probe", 1_000, accessibility, 7, true, true, true, false)
|
||||
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
|
||||
}
|
||||
}
|
||||
+327
-99
@@ -12,6 +12,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
@@ -24,13 +26,197 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `slow first browser lookup cannot compress the actual retry interval`() {
|
||||
var now = 0L
|
||||
val clickedAt = mutableListOf<Long>()
|
||||
val underlying = FakePurchaseDriver().apply { browser = true }
|
||||
val driver = object : PurchaseUiDriver by underlying {
|
||||
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
|
||||
if (clickedAt.isEmpty()) now += 900
|
||||
if (allowed()) clickedAt += now
|
||||
return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE)
|
||||
}
|
||||
}
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertTrue(clickedAt.size > 1)
|
||||
assertTrue(clickedAt.zipWithNext().all { (a, b) -> b - a >= 1_000 })
|
||||
assertTrue(clickedAt.all { it < 30_000 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unapproved package with matching open button is never clicked`() {
|
||||
var now = 0L
|
||||
val underlying = FakePurchaseDriver().apply { browser = true }
|
||||
val driver = object : PurchaseUiDriver by underlying {
|
||||
override fun capture() = underlying.capture().copy(packageName = "other.application")
|
||||
}
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertTrue(underlying.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `launch dispatch consumes the navigation deadline before first capture`() {
|
||||
var now = 0L
|
||||
var captures = 0
|
||||
val underlying = FakePurchaseDriver()
|
||||
val driver = object : PurchaseUiDriver by underlying {
|
||||
override fun capture(): UiSnapshot { captures++; return underlying.capture() }
|
||||
}
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { now = 30_000; true }, { error("probe must not run") },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.DIRECT }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertEquals(0, captures)
|
||||
assertTrue(underlying.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handoff invalidated during fresh capture reopens instead of reusing`() {
|
||||
var now = 29_999L
|
||||
var opens = 0
|
||||
val underlying = FakePurchaseDriver()
|
||||
val driver = object : PurchaseUiDriver by underlying {
|
||||
override fun capture(): UiSnapshot { now = 30_000; return underlying.capture() }
|
||||
}
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { opens++; false }, { null }, pause = {},
|
||||
monotonicNow = { now }, confirmProbeHandoff = { now < 30_000 },
|
||||
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(1, opens)
|
||||
assertEquals("PDD_LINK_INVALID", outcome.errorCode)
|
||||
assertTrue(underlying.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input reuse flag defaults false`() {
|
||||
val task = PurchaseExecutionInput(42, "rehearsal", "purchase", input().url, input().goodsId,
|
||||
"黑色", "XL", quantity = 2, minUnitPriceCent = 1000, maxUnitPriceCent = 3000)
|
||||
assertFalse(task.reuseProbeProduct)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diag3 timeline waits for browser and returned product buffer before any spec action`() {
|
||||
var now = 921L
|
||||
var probeAt = -1L
|
||||
val underlying = FakePurchaseDriver()
|
||||
val clicksAt = mutableListOf<Long>()
|
||||
val browserSnapshot = FakePurchaseDriver().apply { browser = true }.capture()
|
||||
val driver = object : PurchaseUiDriver by underlying {
|
||||
override fun capture(): UiSnapshot {
|
||||
if (now in 1_739 until 12_189) return browserSnapshot
|
||||
val current = underlying.capture()
|
||||
return if (now < 13_218) current else current.copy(nodes = current.nodes.map {
|
||||
if (it.path == "title") it.copy(text = "另一件商品标题文本") else it
|
||||
})
|
||||
}
|
||||
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
|
||||
assertTrue(now in 1_739 until 12_189)
|
||||
clicksAt += now
|
||||
return FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS)
|
||||
}
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
assertTrue("spec action at $now", now >= 15_200)
|
||||
return underlying.clickFreshDetailed(target)
|
||||
}
|
||||
}
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { probeAt = now; "{}" },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe", reuseProbeProduct = false), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertTrue(probeAt >= 15_200)
|
||||
assertTrue(clicksAt.zipWithNext().all { (a, b) -> b - a >= 1_000 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slow capture crossing deadline cannot click browser or enter specs`() {
|
||||
var now = 0L
|
||||
val underlying = FakePurchaseDriver().apply { browser = true }
|
||||
val driver = object : PurchaseUiDriver by underlying {
|
||||
override fun capture(): UiSnapshot { now = 30_000; return underlying.capture() }
|
||||
}
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertTrue(underlying.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fresh browser node lookup crossing deadline declines action`() {
|
||||
var now = 0L
|
||||
val underlying = FakePurchaseDriver().apply { browser = true }
|
||||
val driver = object : PurchaseUiDriver by underlying {
|
||||
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
|
||||
now = 30_000
|
||||
assertFalse(allowed())
|
||||
return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
|
||||
}
|
||||
}
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertTrue(underlying.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct accepted launch buffers same package product without empty frame`() {
|
||||
var now = 0L
|
||||
var probeAt = -1L
|
||||
val driver = FakePurchaseDriver()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { probeAt = now; "{}" },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.DIRECT }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertTrue(probeAt >= 3_000)
|
||||
assertEquals(0, driver.openClickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `browser launch without observed browser times out on stable old pdd`() {
|
||||
var now = 0L
|
||||
val driver = FakePurchaseDriver()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
|
||||
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertEquals(30_000L, now)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `untrusted current product does not bypass navigation`() {
|
||||
var opens = 0
|
||||
val outcome = executor(FakePurchaseDriver(), { opens++; false }, { null }, pause = {})
|
||||
.execute(input().copy(reuseProbeProduct = false), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(1, opens)
|
||||
assertEquals("PDD_LINK_INVALID", outcome.errorCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `old pdd product without accepted launch evidence times out before probe`() {
|
||||
var probes = 0
|
||||
val driver = FakePurchaseDriver()
|
||||
val outcome = executor(driver, { true }, { probes++; "{}" }, pause = {}, path = null)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `probe skips post open swipes but preserves wait and records click`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val clicks = mutableListOf<FreshClickOutcome>()
|
||||
val configured = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":1234")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" },
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" },
|
||||
pause = pauses::add, probeClickDiagnostic = { _, _, click -> clicks += click },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
@@ -43,7 +229,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `blocked entry stops without gesture or probe`() {
|
||||
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
@@ -53,7 +239,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `blocked size stops without gesture retry`() {
|
||||
val driver = FakePurchaseDriver(forcedSizeClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
@@ -64,7 +250,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `blocked gesture stops without another click`() {
|
||||
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.ACTION_CLICK_FALSE, specTapResult = FreshActionResult.BLOCKED)
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
@@ -74,7 +260,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `purchase still fails required post open swipe`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
@@ -83,7 +269,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `color selection then single size heading completes without selecting color again`() {
|
||||
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
@@ -93,7 +279,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `full exact summary confirms size after option leaves viewport`() {
|
||||
val target = "2XL 建议131到150斤"
|
||||
val driver = FakePurchaseDriver(sizes = listOf(target), hideSizeAfterSelection = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == target })
|
||||
@@ -106,7 +292,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
sizes = listOf(target), hideSizeAfterSelection = true,
|
||||
selectedSizeSummaryOverride = "2XL 建议151到170斤",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("failed", outcome.resultType)
|
||||
}
|
||||
@@ -160,7 +346,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
var openCount = 0
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
val outcome = executor(
|
||||
driver,
|
||||
openLink = { openCount++; driver.browser = true; true },
|
||||
probeSpecs = { null },
|
||||
@@ -181,7 +367,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `manual purchase retry from agent reopens the task product url`() {
|
||||
val driver = FakePurchaseDriver(initiallyInAgent = true)
|
||||
var openCount = 0
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
val outcome = executor(
|
||||
driver,
|
||||
openLink = {
|
||||
openCount++
|
||||
@@ -201,7 +387,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `purchase phase reopens when pdd foreground has no product evidence`() {
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
|
||||
var openCount = 0
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
val outcome = executor(
|
||||
driver,
|
||||
openLink = { openCount++; driver.browser = true; true },
|
||||
probeSpecs = { null },
|
||||
@@ -217,7 +403,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `purchase phase does not reuse a pdd login page`() {
|
||||
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
|
||||
var openCount = 0
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
val outcome = executor(
|
||||
driver,
|
||||
openLink = { openCount++; driver.browser = true; true },
|
||||
probeSpecs = { null },
|
||||
@@ -231,7 +417,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `price range failure reports the observed unit price`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(maxUnitPriceCent = 1_999), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode)
|
||||
@@ -246,7 +432,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
"\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\"",
|
||||
"\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\",\"purchase.future.v1\"",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { openCount++; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { openCount++; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(unsupported), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("AGENT_CAPABILITY_MISMATCH", outcome.errorCode)
|
||||
assertEquals(0, openCount)
|
||||
@@ -256,7 +442,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `missing exact spec returns probe and never chooses similar value`() {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色加绒"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals("{\"dimensions\":[]}", outcome.probedSpecs)
|
||||
@@ -268,7 +454,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `missing exact spec without server permission preserves the real failure`() {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色加绒"))
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{\"dimensions\":[]}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{\"dimensions\":[]}" }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -280,7 +466,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `exact size below the visible panel is found after a bounded vertical search`() {
|
||||
val driver = FakePurchaseDriver(hiddenSizeUntilUpSwipes = 3)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -295,7 +481,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -307,7 +493,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `visible exact color keeps established path without horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -319,7 +505,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色"), listOf("富贵粉")),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
@@ -334,7 +520,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
horizontalSpecSwipeSucceeds = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
@@ -351,7 +537,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
listOf("2XL 推荐125-140斤", targetSize),
|
||||
),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -366,7 +552,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalSizePages = listOf(listOf("S 推荐80-95斤"), listOf(targetSize)),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -381,7 +567,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
horizontalSizePages = listOf(listOf("S 推荐80-95斤", "M 推荐95-110斤"), listOf("2XL 推荐125-140斤", targetSize)),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(
|
||||
input().copy(mappedColor = "富贵粉", mappedSize = targetSize),
|
||||
PurchaseRuleParser.parse(rule()),
|
||||
@@ -398,7 +584,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `two column size grid continues vertically without horizontal container`() {
|
||||
val target = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(sizes = listOf(target), revealGridSizeAfterUpSwipes = 7)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(target, driver.size)
|
||||
@@ -415,7 +601,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
horizontalSpecSwipeSucceeds = false,
|
||||
revealGridSizeAfterUpSwipes = 7,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(target, driver.size)
|
||||
@@ -425,7 +611,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `missing size terminates at stable viewport with bounded gestures`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("S", "M"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("reason=stableViewport"))
|
||||
@@ -436,7 +622,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `visible exact size does not enter horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("L", "XL"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -449,7 +635,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
|
||||
sizeSelectsOnFailedClick = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -463,7 +649,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -475,7 +661,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `already selected exact size is not clicked again`() {
|
||||
val driver = FakePurchaseDriver(initialSize = "XL")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -491,7 +677,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
selectedSizeSummaryOverride = "XL",
|
||||
hideSizeSelectedState = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -501,7 +687,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `final verification accepts current attempt proof when selected color scrolls out of viewport`() {
|
||||
val driver = FakePurchaseDriver(hideColorAfterQuantitySet = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -517,7 +703,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
selectedSizeSummaryOverride = "XL",
|
||||
hideSizeAfterQuantitySet = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -530,17 +716,42 @@ class PurchaseRehearsalExecutorTest {
|
||||
hideColorAfterQuantitySet = true,
|
||||
hideSelectedSummaryAfterQuantitySet = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible unselected target without summary cannot reuse historical proof`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("L"), hideSelectedSummaryAfterQuantitySet = true, hideSizeSelectedState = true)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = "L"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("failed", outcome.resultType)
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
assertTrue(outcome.message, outcome.message.contains("reason=target_visible_unselected"))
|
||||
assertTrue(outcome.message, outcome.message.contains("proofRecorded=true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible unselected normalized target blocks historical proof without relocation`() {
|
||||
val target = "L【建议105-115斤】"
|
||||
val driver = FakePurchaseDriver(
|
||||
sizes = listOf(target), hideSelectedSummaryAfterQuantitySet = true,
|
||||
finalSizesAfterQuantitySet = listOf("L[建议105-115斤]"),
|
||||
)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
assertTrue(outcome.message, outcome.message.contains("reason=target_visible_unselected"))
|
||||
assertFalse(driver.swipeInPaths.contains("scroll"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
|
||||
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(quantity = 1), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -555,7 +766,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
hideSelectedSummaryAfterQuantitySet = true,
|
||||
restoreHiddenColorOnDownSwipe = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -571,7 +782,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
hideSelectedSummaryAfterQuantitySet = true,
|
||||
selectedColorOverrideAfterQuantitySet = "白色",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -589,7 +800,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
hideSizeAfterQuantitySet = true,
|
||||
selectedSummaryOverrideAfterQuantitySet = "黑色 2XL",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -606,7 +817,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
selectedSizeSummaryOverride = "XL",
|
||||
finalSizesAfterQuantitySet = listOf("XL【建议B】"),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -624,7 +835,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
selectedSizeSummaryOverride = "2XL",
|
||||
hideSizeSelectedState = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -639,7 +850,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
selectedSizeSummaryOverride = "XL",
|
||||
hideSizeSelectedState = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -651,7 +862,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val driver = FakePurchaseDriver(
|
||||
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
|
||||
@@ -672,7 +883,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
cases.forEach { (reason, expected) ->
|
||||
val driver = FakePurchaseDriver(forcedSizeClickReason = reason)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
|
||||
@@ -684,7 +895,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `visible unavailable exact target fails as safe target missing without probing`() {
|
||||
val driver = FakePurchaseDriver(unavailableSizes = setOf("XL"))
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
|
||||
@@ -697,7 +908,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
listOf("¥15.78", "XL ¥15.78起").forEach { unsafeTarget ->
|
||||
val driver = FakePurchaseDriver()
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(mappedSize = unsafeTarget, specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
|
||||
@@ -709,7 +920,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `legacy task target with a safe trailing price selects the clean page value`() {
|
||||
val clean = "XL【建议121-140斤】"
|
||||
val driver = FakePurchaseDriver(sizes = listOf(clean))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = "$clean ¥15.78"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -719,7 +930,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `two raw page values that normalize to one size fail as ambiguous`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("XL ¥15.78", "XL ¥16.80"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -730,7 +941,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `price outside range fails before any order action`() {
|
||||
val driver = FakePurchaseDriver(priceCent = 4_000)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode)
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
|
||||
@@ -739,7 +950,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `ambiguous browser target stops safely`() {
|
||||
val driver = FakePurchaseDriver(duplicateOpen = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("RULE_AMBIGUOUS", outcome.errorCode)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
@@ -751,7 +962,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
openClickResults = mutableListOf(FreshActionResult.FAILED),
|
||||
openPddOnFailedClick = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
@@ -764,7 +975,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
|
||||
)
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
@@ -775,46 +986,46 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `persistent open click failure is bounded and remains safely failed`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
openClickResults = MutableList(20) { FreshActionResult.FAILED },
|
||||
openClickResults = MutableList(40) { FreshActionResult.FAILED },
|
||||
)
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(driver.openClickCount in 1..15)
|
||||
assertEquals(150, pauses.size)
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertEquals(30, driver.openClickCount)
|
||||
assertEquals(300, pauses.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five second browser interstitial can still reach a stable product page`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(browserOpenVisibleAfterCaptures = 50)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, driver.openClickCount)
|
||||
assertTrue(pauses.count { it == 100L } in 52..60)
|
||||
assertTrue(pauses.count { it == 100L } in 82..90)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdd foreground without stable product evidence times out at fifteen seconds`() {
|
||||
fun `pdd foreground without stable product evidence times out at thirty seconds`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 200)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 400)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertEquals("打开拼多多后未识别到稳定商品页面", outcome.message)
|
||||
assertEquals(150, pauses.count { it == 100L })
|
||||
assertEquals(300, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit pdd login page fails before the product timeout`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
|
||||
@@ -825,7 +1036,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -841,7 +1052,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
val outcome = executor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
@@ -857,7 +1068,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
val outcome = executor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
@@ -876,7 +1087,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0),
|
||||
))
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
@@ -891,7 +1102,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0),
|
||||
))
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -903,7 +1114,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `scrollable spec reveal preserves configured swipe count and interval`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -918,7 +1129,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
"\"waitAfterMs\":700}",
|
||||
"\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
@@ -930,7 +1141,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `skipping a reveal does not skip a failed specification probe`() {
|
||||
val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet())
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode)
|
||||
@@ -940,7 +1151,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `unrecognized opened panel returns only scalar panel evidence`() {
|
||||
val driver = FakePurchaseDriver(unrecognizedPanel = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
@@ -959,7 +1170,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
entryActionHasEffect = false,
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -974,7 +1185,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
specTapHasEffect = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
|
||||
@@ -988,7 +1199,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
entryActionHasEffect = false,
|
||||
specTapResult = FreshActionResult.FAILED,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
|
||||
@@ -1001,7 +1212,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(missingSpecEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
val outcome = executor(
|
||||
driver,
|
||||
{ driver.browser = true; true },
|
||||
{ null },
|
||||
@@ -1022,33 +1233,31 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `open spec panel waits for a late safe bottom purchase entry`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertTrue(driver.clickedPaths.contains("buy"))
|
||||
// One 100ms pause belongs to the existing open-product foreground poll;
|
||||
// two belong to the entry-ready wait before the bottom bar appears.
|
||||
assertEquals(3, pauses.count { it == 100L })
|
||||
// The browser return gets a full three-second buffer before the spec entry can be used.
|
||||
assertEquals(32, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase reopens and waits for product evidence instead of a visible loading frame`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
// Two stable reads belong to reopening the product; verifyProduct then
|
||||
// independently requires its second stable read before continuing.
|
||||
assertEquals(3, pauses.count { it == 100L })
|
||||
// Browser return buffer overlaps stable reads; verifyProduct performs its independent check.
|
||||
assertEquals(32, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient sold out page recovers before opening specs`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -1059,7 +1268,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `persistent sold out page returns replacement eligible error`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
|
||||
@@ -1070,7 +1279,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `sold out recovery gesture failure stops immediately`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, pullDownSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
|
||||
@@ -1080,7 +1289,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `sold out recovery rejects a page that lost product evidence`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, loseEvidenceAfterPull = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_NOT_MATCHED", outcome.errorCode)
|
||||
@@ -1091,7 +1300,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `all unavailable specs recover on goods page and reopen panel`() {
|
||||
val driver = FakePurchaseDriver(allSpecsUnavailable = true, recoverSoldOutAfterPull = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
@@ -1102,7 +1311,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `goods id mismatch fails before opening pdd`() {
|
||||
var opened = false
|
||||
val outcome = PurchaseRehearsalExecutor(FakePurchaseDriver(), { opened = true; true }, { null }, pause = {})
|
||||
val outcome = executor(FakePurchaseDriver(), { opened = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(goodsId = "999"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PDD_GOODS_MISMATCH", outcome.errorCode)
|
||||
assertFalse(opened)
|
||||
@@ -1111,7 +1320,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `live rule spec probe ignores historical mapping and returns before address or order actions`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
.execute(
|
||||
input().copy(
|
||||
executionMode = "live",
|
||||
@@ -1131,7 +1340,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `spec probe opens panel through safe bottom purchase entry`() {
|
||||
val driver = FakePurchaseDriver(bottomPurchaseEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
@@ -1142,7 +1351,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `purchase returns once and fails explicitly when spec entry opens reviews`() {
|
||||
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, openReviewOnBottomClick = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
@@ -1163,7 +1372,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
)
|
||||
cases.forEach { (reason, expected) ->
|
||||
val driver = FakePurchaseDriver(forcedEntryClickReason = reason)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
|
||||
@@ -1179,7 +1388,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
openReviewOnBottomClick = true,
|
||||
reviewBackSucceeds = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_OPENED_REVIEW", outcome.errorCode)
|
||||
@@ -1192,7 +1401,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
fun `spec aliases cannot bypass semantic safe candidates`() {
|
||||
val driver = FakePurchaseDriver(includeReviewEntry = true)
|
||||
val unsafeRule = rule().replace("[\"选择规格\"]", "[\"商品评价\"]")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(unsafeRule), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
@@ -1202,7 +1411,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `spec entry failures include scalar diagnostics only`() {
|
||||
val driver = FakePurchaseDriver(missingSpecEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
@@ -1212,7 +1421,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
)
|
||||
|
||||
val ambiguousDriver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.TARGET_AMBIGUOUS)
|
||||
val ambiguous = PurchaseRehearsalExecutor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
|
||||
val ambiguous = executor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
|
||||
@@ -1329,6 +1538,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(1, submitted.map { it.payloadJson }.distinct().size)
|
||||
}
|
||||
|
||||
/** Existing spec/price tests explicitly model the trusted immediate probe handoff. */
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 42,
|
||||
executionMode = "rehearsal",
|
||||
@@ -1340,8 +1550,26 @@ class PurchaseRehearsalExecutorTest {
|
||||
quantity = 2,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
reuseProbeProduct = true,
|
||||
)
|
||||
|
||||
private fun executor(
|
||||
driver: PurchaseUiDriver,
|
||||
openLink: (String) -> Boolean,
|
||||
probeSpecs: () -> String?,
|
||||
pause: (Long) -> Unit,
|
||||
panelDiagnostic: (String) -> Unit = {},
|
||||
probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
|
||||
path: PddLaunchPath? = PddLaunchPath.BROWSER,
|
||||
): PurchaseRehearsalExecutor {
|
||||
var now = 0L
|
||||
return PurchaseRehearsalExecutor(driver, openLink, probeSpecs,
|
||||
pause = { millis -> now += millis; pause(millis) },
|
||||
panelDiagnostic = panelDiagnostic, probeClickDiagnostic = probeClickDiagnostic,
|
||||
acceptedLaunchPath = { path }, monotonicNow = { now },
|
||||
)
|
||||
}
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.network.PurchaseResultAcknowledgement
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseResultAcknowledgementTest {
|
||||
@Test fun `server omits replayed false on pending first response`() {
|
||||
val ack = PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending"}"""))
|
||||
assertTrue(ack.pending)
|
||||
assertFalse(ack.replayed)
|
||||
}
|
||||
@Test fun `failed matching and explicit or malformed replay never qualify`() {
|
||||
assertFalse(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"failed"}""")).pending)
|
||||
assertTrue(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending","replayed":true}""")).replayed)
|
||||
assertTrue(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending","replayed":"invalid"}""")).replayed)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ class SpecOptionDedupTest {
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 332,
|
||||
reuseProbeProduct = true, // This test starts at the trusted probe-to-purchase spec handoff.
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
|
||||
@@ -502,7 +502,7 @@ class SpecPanelRecognitionTest {
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/real-submit", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 2135, 1080, 2200), clickable = true, parent = "r"),
|
||||
node("r/real-submit", "提交订单", NodeBounds(0, 2135, 1080, 2200), clickable = true, parent = "r"),
|
||||
// Zero-*area* (right==left) and lower on screen: must never win despite
|
||||
// having its own non-blank label and a clickable ancestor.
|
||||
node("r/decoy", "¥0.0", NodeBounds(500, 2200, 500, 2216), clickable = true, parent = "r"),
|
||||
@@ -512,7 +512,7 @@ class SpecPanelRecognitionTest {
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("选择颜色分类及尺码后,提交订单"), driver.clicked)
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -716,6 +716,7 @@ class SpecPanelRecognitionTest {
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 331,
|
||||
reuseProbeProduct = true, // Panel tests start at the trusted probe-to-purchase handoff.
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
|
||||
@@ -8,6 +8,15 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SpecValueNormalizerTest {
|
||||
@Test
|
||||
fun `final confirmation maps only display brackets alphanumerics and whitespace`() {
|
||||
assertEquals("[白][黑][灰](薄款) RW12 XL", SpecValueNormalizer.normalizeFinalConfirmation(" 【白】〔黑〕[灰](薄款) RW12\t\nXL "))
|
||||
assertEquals("白色,加绒:XL ¥19.60 ① 2件", SpecValueNormalizer.normalizeFinalConfirmation("白色,加绒:XL ¥19.60 ① 2件"))
|
||||
assertEquals("xl", SpecValueNormalizer.normalizeFinalConfirmation("xl"))
|
||||
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeColor("【白色】 XL"))
|
||||
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeSize("【白色】 XL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only trailing price is removed`() {
|
||||
assertEquals("紫色", SpecValueNormalizer.normalizeColor("紫色 ¥15.78"))
|
||||
|
||||
@@ -196,7 +196,13 @@ class TruncatedSpecCardTest {
|
||||
var sizeSelected = false
|
||||
val clicks = mutableListOf<String>()
|
||||
override fun capture() = if (opened) snapshot.copy(nodes = snapshot.nodes.map { n ->
|
||||
if (n.path.contains("/size/o")) n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2")) else n
|
||||
when {
|
||||
n.path.contains("/size/o") -> n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2"))
|
||||
// #376: a completed fake selection must no longer expose PDD's incomplete-selection prompts.
|
||||
sizeSelected && n.label.startsWith("请选择") -> n.copy(text = "已选 黑色示例长裤【有抽绳】 有口袋不起球 2XL建议130-150斤")
|
||||
sizeSelected && n.label == "选择颜色分类及尺码后,提交订单" -> n.copy(text = "提交订单")
|
||||
else -> n
|
||||
}
|
||||
}) else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks += target.label
|
||||
@@ -213,7 +219,8 @@ class TruncatedSpecCardTest {
|
||||
|
||||
private fun run(snapshot: UiSnapshot, target: String = full, driver: Driver = Driver(snapshot)) = executor(driver).execute(
|
||||
PurchaseExecutionInput(362, "rehearsal", "purchase", "https://mobile.yangkeduo.com/goods.html?goods_id=123456789",
|
||||
"123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000),
|
||||
"123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000,
|
||||
reuseProbeProduct = true), // Exact-card tests explicitly model the immediate trusted handoff.
|
||||
PurchaseRuleParser.parse("""{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}"""),
|
||||
@@ -241,7 +248,12 @@ class TruncatedSpecCardTest {
|
||||
}
|
||||
|
||||
@Test fun `existing final confirmation target first ordering is documented not changed`() {
|
||||
val screen = parse(sheet(otherSelected = true))
|
||||
val snapshot = sheet(otherSelected = true).let { sheet -> sheet.copy(nodes = sheet.nodes.map { node -> when {
|
||||
node.label.startsWith("请选择") -> node.copy(text = "已选 $full $size")
|
||||
node.label == "选择颜色分类及尺码后,提交订单" -> node.copy(text = "提交订单")
|
||||
else -> node
|
||||
} }) }
|
||||
val screen = parse(snapshot)
|
||||
val executor = executor(Driver(sheet()))
|
||||
val immediate = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "isExactSpecSelected" }
|
||||
immediate.isAccessible = true
|
||||
|
||||
+177
-1
@@ -3,10 +3,112 @@ package cn.ilapage.goauto.agent.persistence
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentDiagnosticStoreMigrationTest {
|
||||
@Test
|
||||
fun freshV5DatabaseHasNullableColorDiscoveryOutcomeColumns() = withDatabase { db ->
|
||||
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
|
||||
assertEquals(5, AgentDiagnosticSchema.VERSION)
|
||||
assertV5Columns(db)
|
||||
insertOldDiagnostic(db)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
for (incomplete in listOf(0, 1)) {
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeUpdate(
|
||||
"UPDATE agent_diagnostic SET color_vertical_swipe_count=4, " +
|
||||
"color_trailing_empty_read_count=2, color_horizontal_incomplete=$incomplete",
|
||||
)
|
||||
statement.executeQuery(
|
||||
"SELECT color_vertical_swipe_count, color_trailing_empty_read_count, " +
|
||||
"color_horizontal_incomplete FROM agent_diagnostic",
|
||||
).use { row ->
|
||||
assertTrue(row.next())
|
||||
assertEquals(4, row.getInt(1))
|
||||
assertEquals(2, row.getInt(2))
|
||||
assertEquals(incomplete, row.getInt(3))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everySupportedVersionUpgradesToV5WithoutChangingExistingRows() {
|
||||
for (oldVersion in 1..4) withDatabase { db ->
|
||||
createOldDatabase(db, oldVersion)
|
||||
insertOldDiagnostic(db)
|
||||
val existingTables = snapshotTables(db)
|
||||
|
||||
migrate(db, oldVersion, 5)
|
||||
|
||||
assertV5Columns(db)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys))
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys))
|
||||
assertEquals(3, snapshotTables(db).size)
|
||||
migrate(db, oldVersion, 5)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v4UpgradeAddsOnlyMissingColumnsAndKeepsExistingV5Values() = withDatabase { db ->
|
||||
createOldDatabase(db, 4)
|
||||
insertOldDiagnostic(db)
|
||||
db.createStatement().use {
|
||||
it.execute("ALTER TABLE agent_diagnostic ADD COLUMN color_vertical_swipe_count INTEGER")
|
||||
it.executeUpdate("UPDATE agent_diagnostic SET color_vertical_swipe_count=3")
|
||||
}
|
||||
val statements = AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db))
|
||||
assertEquals(2, statements.size)
|
||||
assertTrue(statements.all { it.startsWith("ALTER TABLE agent_diagnostic ADD COLUMN ") })
|
||||
migrate(db, 4, 5)
|
||||
assertV5Columns(db)
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT color_vertical_swipe_count, color_trailing_empty_read_count, color_horizontal_incomplete FROM agent_diagnostic").use {
|
||||
assertTrue(it.next())
|
||||
assertEquals(3, it.getInt(1))
|
||||
assertNull(it.getObject(2))
|
||||
assertNull(it.getObject(3))
|
||||
}
|
||||
}
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(5, 5, columnNames(db)).isEmpty())
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 4, columnNames(db)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v5UpgradeSurvivesDatabaseReopen() {
|
||||
val file = java.io.File.createTempFile("goauto-diagnostic-migration-", ".db")
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC")
|
||||
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
|
||||
createOldDatabase(db, 4)
|
||||
insertOldDiagnostic(db)
|
||||
migrate(db, 4, 5)
|
||||
db.createStatement().use { it.execute("PRAGMA user_version=5") }
|
||||
}
|
||||
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
|
||||
assertV5Columns(db)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
|
||||
assertEquals(3, snapshotTables(db).size)
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("PRAGMA user_version").use {
|
||||
assertTrue(it.next())
|
||||
assertEquals(5, it.getInt(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
check(file.delete()) { "Could not remove temporary migration test database" }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v4AddsIndependentFailureSnapshotQueueWithoutChangingStructuredRows() = withDatabase { db ->
|
||||
db.createStatement().use {
|
||||
@@ -16,7 +118,6 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
AgentDiagnosticSchema.migrationStatements(3, 4, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
assertEquals(4, AgentDiagnosticSchema.VERSION)
|
||||
for (table in listOf("purchase_failure_snapshot", "purchase_failure_snapshot_queue", "purchase_failure_snapshot_context")) {
|
||||
db.prepareStatement("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?").use {
|
||||
it.setString(1, table)
|
||||
@@ -137,6 +238,80 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
assertEquals(1, rowCount(db))
|
||||
}
|
||||
|
||||
private fun assertV5Columns(db: Connection) {
|
||||
val actual = mutableSetOf<String>()
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("PRAGMA table_info(agent_diagnostic)").use { rows ->
|
||||
while (rows.next()) {
|
||||
val name = rows.getString("name")
|
||||
if (name in V5_COLUMNS) {
|
||||
actual.add(name)
|
||||
assertEquals(name, "INTEGER", rows.getString("type"))
|
||||
assertEquals(name, 0, rows.getInt("notnull"))
|
||||
assertNull(name, rows.getObject("dflt_value"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(V5_COLUMNS, actual)
|
||||
}
|
||||
|
||||
private fun createOldDatabase(db: Connection, version: Int) {
|
||||
db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) }
|
||||
migrate(db, 1, version)
|
||||
if (version >= 4) db.createStatement().use {
|
||||
it.execute("INSERT INTO purchase_failure_snapshot VALUES ('attempt','test-origin',106,7,'spec_probe',1000,2000,'test','ready','{}',X'0102','test-fingerprint',2)")
|
||||
it.execute("INSERT INTO purchase_failure_snapshot_queue VALUES ('attempt','test-fingerprint',1500,2)")
|
||||
it.execute("INSERT INTO purchase_failure_snapshot_context VALUES ('attempt','test-origin','{}',1000)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertOldDiagnostic(db: Connection) {
|
||||
db.createStatement().use {
|
||||
it.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (106,'COLOR_DISCOVERY','COLOR_EDGE_REACHED',2,10,'old',1000)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertOldDiagnosticPreserved(db: Connection) {
|
||||
assertEquals(1, rowCount(db))
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT id,task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic").use {
|
||||
assertTrue(it.next())
|
||||
assertEquals(1L, it.getLong(1))
|
||||
assertEquals(106L, it.getLong(2))
|
||||
assertEquals("COLOR_DISCOVERY", it.getString(3))
|
||||
assertEquals("COLOR_EDGE_REACHED", it.getString(4))
|
||||
assertEquals(2, it.getInt(5))
|
||||
assertEquals(10L, it.getLong(6))
|
||||
assertEquals("old", it.getString(7))
|
||||
assertEquals(1000L, it.getLong(8))
|
||||
for (index in 9..11) assertNull(it.getObject(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotTables(db: Connection): Map<String, List<List<String?>>> =
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'purchase_failure_snapshot%'").use { rows ->
|
||||
buildList { while (rows.next()) add(rows.getString(1)) }
|
||||
}
|
||||
}.associateWith { tableRows(db, it) }
|
||||
|
||||
private fun tableRows(db: Connection, table: String): List<List<String?>> =
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT * FROM $table ORDER BY attempt_id").use { rows ->
|
||||
buildList {
|
||||
while (rows.next()) add((1..rows.metaData.columnCount).map { rows.getString(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrate(db: Connection, oldVersion: Int, newVersion: Int) {
|
||||
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateV1ToV2(db: Connection) {
|
||||
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
@@ -166,6 +341,7 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val V5_COLUMNS = setOf("color_vertical_swipe_count", "color_trailing_empty_read_count", "color_horizontal_incomplete")
|
||||
val CREATE_V1_TABLE_SQL =
|
||||
"""CREATE TABLE agent_diagnostic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.BrowserProbeClickPolicy
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class BrowserProbeClickPolicyTest {
|
||||
@Test fun onlyExactNativeBrowserEntryIsAllowed() {
|
||||
assertTrue(BrowserProbeClickPolicy.allowed("com.heytap.browser", "com.heytap.browser", "打开拼多多APP", true, true, true, 3, 3))
|
||||
}
|
||||
@Test fun rejectsPackageSwitchHiddenDisabledNonClickableAndStaleWindow() {
|
||||
fun allowed(root: String? = "com.heytap.browser", node: String? = root, label: String = "打开拼多多APP",
|
||||
visible: Boolean = true, enabled: Boolean = true, clickable: Boolean = true, window: Int = 3) =
|
||||
BrowserProbeClickPolicy.allowed(root, node, label, visible, enabled, clickable, 3, window)
|
||||
assertFalse(allowed(root = "com.xunmeng.pinduoduo"))
|
||||
assertFalse(allowed(node = "com.xunmeng.pinduoduo"))
|
||||
assertFalse(allowed(root = null))
|
||||
assertFalse(allowed(label = "打开"))
|
||||
assertFalse(allowed(label = "立即支付"))
|
||||
assertFalse(allowed(visible = false))
|
||||
assertFalse(allowed(enabled = false))
|
||||
assertFalse(allowed(clickable = false))
|
||||
assertFalse(allowed(window = 4))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.diagnostics.NavigationProbe
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class NavigationProbeTest {
|
||||
@Test fun cancellationDuringInitialCapturePreventsLaunch() {
|
||||
var active = true
|
||||
var launches = 0
|
||||
val page = UiSnapshot("com.xunmeng.pinduoduo", null, emptyList())
|
||||
NavigationProbe(capture = { active = false; page }, launch = { launches++; true },
|
||||
clickBrowserEntry = { error("no click") }, now = { 0 }, pause = {}, emit = {},
|
||||
isActive = { active }).run()
|
||||
assertEquals(0, launches)
|
||||
}
|
||||
@Test fun deadlineCrossedDuringCapturePreventsLaunch() {
|
||||
var clock = 0L
|
||||
var launches = 0
|
||||
NavigationProbe(capture = { clock = 20_001; UiSnapshot(null, null, emptyList()) },
|
||||
launch = { launches++; true }, clickBrowserEntry = { error("no click") }, now = { clock },
|
||||
pause = {}, emit = {}).run()
|
||||
assertEquals(0, launches)
|
||||
}
|
||||
@Test fun cancellationDuringBrowserCapturePreventsClick() {
|
||||
var active = true
|
||||
var reads = 0
|
||||
var clock = 0L
|
||||
var clicks = 0
|
||||
val page = UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP")))
|
||||
NavigationProbe(capture = { reads++; if (reads > 1) active = false; page }, launch = { true },
|
||||
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
|
||||
now = { clock }, pause = { clock += it }, emit = {}, isActive = { active }).run()
|
||||
assertEquals(0, clicks)
|
||||
}
|
||||
@Test fun baselineUsesActualPackageRatherThanOptionalParserEvidence() {
|
||||
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("发起拼单"))))
|
||||
rig.run()
|
||||
assertTrue(rig.lines.first().contains("pdd=false"))
|
||||
assertFalse(rig.lines.any { it.contains("legacy_would_accept") })
|
||||
}
|
||||
private class Rig(var page: UiSnapshot) {
|
||||
var clock = 0L
|
||||
var launches = 0
|
||||
var clicks = 0
|
||||
val lines = mutableListOf<String>()
|
||||
fun run(accepted: Boolean = true) = NavigationProbe(
|
||||
capture = { page },
|
||||
launch = { report -> launches++; report(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)); accepted },
|
||||
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
|
||||
now = { clock }, pause = { clock += it }, emit = lines::add,
|
||||
).run()
|
||||
}
|
||||
private fun node(text: String, path: String = "0") = SnapshotNode(path, null, text, null, null,
|
||||
"android.widget.TextView", NodeBounds(0, 0, 100, 100), true, false, false, false, true, true)
|
||||
|
||||
@Test fun pddIsReadOnlyAndSamplingStopsWithinBound() {
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("立即下单"))))
|
||||
rig.run()
|
||||
assertEquals(1, rig.launches)
|
||||
assertEquals(0, rig.clicks)
|
||||
assertEquals(20_000L, rig.clock)
|
||||
assertTrue(rig.lines.last().contains("finished"))
|
||||
}
|
||||
@Test fun failedLaunchStopsWithoutClicking() {
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, emptyList()))
|
||||
rig.run(false)
|
||||
assertEquals(1, rig.launches)
|
||||
assertEquals(0L, rig.clock)
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.last().contains("launch_failed"))
|
||||
}
|
||||
@Test fun browserRequiresUniqueExplicitPddEntryAndBoundedRetries() {
|
||||
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"))))
|
||||
rig.run()
|
||||
assertEquals(3, rig.clicks)
|
||||
}
|
||||
@Test fun ambiguousBrowserEntryStopsWithoutGuessing() {
|
||||
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"), node("打开拼多多APP", "1"))))
|
||||
rig.run()
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.last().contains("browser_ambiguous"))
|
||||
}
|
||||
@Test fun genericOpenAndPddControlsAreNeverClicked() {
|
||||
for (pkg in listOf("com.heytap.browser", "com.xunmeng.pinduoduo", "other.package")) {
|
||||
val rig = Rig(UiSnapshot(pkg, null, listOf(node("打开"), node("立即支付"), node("确认订单"))))
|
||||
rig.run()
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.isNotEmpty())
|
||||
}
|
||||
}
|
||||
@Test fun loginOrCaptchaStopsBeforeLaunch() {
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("手机号登录"))))
|
||||
rig.run()
|
||||
assertEquals(0, rig.launches)
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.last().contains("page_problem"))
|
||||
}
|
||||
@Test fun pageTextAndExceptionMessagesNeverAppearInOutput() {
|
||||
val secret = "PRIVATE_ACCOUNT_ADDRESS_DO_NOT_LOG"
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node(secret))))
|
||||
rig.run()
|
||||
assertTrue(rig.lines.isNotEmpty())
|
||||
assertFalse(rig.lines.joinToString().contains(secret))
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: bf3ce6efd67fde1fdb6eacd3248f186aea880391
|
||||
synchronized_at: 2026-10-08T10:15:16Z
|
||||
wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
|
||||
synchronized_at: 2026-10-09T07:45:10Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -656,7 +656,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
## Android 重购编排(#367)
|
||||
|
||||
实现绑定 `5430dc1`,工单分支尚未合并/安装/发布;无Server/Web、数据库或接口迁移。
|
||||
实现绑定 `5430dc1`,2026-10-09 已合并 main `e26743c` 并安装集成APK;无Server/Web、数据库或接口迁移,真机批量采购仍待验收。
|
||||
|
||||
- `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。
|
||||
- `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。
|
||||
@@ -664,3 +664,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `AgentForegroundService` 独立单线程scheduled executor每2秒推进编排,历史网络不阻塞心跳或taskExecutor;发现使用独享API客户端,reset只短暂预留既有TaskExecutionMutex。原scheduler继续唯一负责claim/start/真实PDD操作及Outbox,编排不持整轮锁、不新建执行器路径。
|
||||
- 新工作查询同时检查采集next、采购next及本设备活动状态历史,防止采购next优先返回本轮current而遮蔽其他pending。停止信号为原子roundId,配置/身份冻结,仅内存保存摘要;进程重启不恢复整轮,既有单任务恢复及失败私有诊断不变。
|
||||
- `AgentApiClient` 仅补读已有采购payload的attemptNumber和标准HTTP Date响应元数据;不添加服务端字段或API。Date缺失清空样本,不沿用手机时间。
|
||||
|
||||
## Android 颜色发现诊断追加(#370 第一阶段)
|
||||
|
||||
实现绑定 `9e5f09a`,已合并 main,2026-10-09 随集成版本 `e26743c` 安装;本阶段不修改颜色遍历和采购结果判定。
|
||||
|
||||
- `AgentDiagnosticSchema` 升至 v5,在 `agent_diagnostic` 追加可空 INTEGER `color_vertical_swipe_count`、`color_trailing_empty_read_count`、`color_horizontal_incomplete`;对应事件中的两个 Int? 和一个 Boolean?,Boolean 按 0/1 写入。新库包含列,v1–v4 逐级补齐并跳过已存在列,旧行新列为 NULL;不清表,不改独立采购失败快照、队列和 context。
|
||||
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
|
||||
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
|
||||
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
|
||||
|
||||
@@ -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: c4870fd01a4569059fc0afc77179d14fbd286384
|
||||
synchronized_at: 2026-10-08T10:15:21Z
|
||||
wiki_revision: f8bb75d1fc0b5e8822186c740ac2034fbd562057
|
||||
synchronized_at: 2026-10-09T09:25:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -375,6 +375,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
|
||||
## SYB 采购强制当次规格探测(#215)
|
||||
|
||||
以下为 #373 之前的复用行为;#373 分支的限定复用和等待规则见“采购页面一次性交接与导航稳定等待”章节,未安装新版不能按新规则判断。
|
||||
|
||||
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
|
||||
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
|
||||
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
|
||||
@@ -676,7 +678,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
## 支付页安全返回后的核单观察(#365)
|
||||
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,已合并 main 并随2026-10-09集成APK安装;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
|
||||
|
||||
- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。
|
||||
- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。
|
||||
@@ -862,7 +864,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 图搜采集部分完成的自动关联资格(#368)
|
||||
|
||||
实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。
|
||||
实现基线:`15ccab3`(2026-10-08);2026-10-09 已合并 main `e26743c` 并部署122.228.200.167,真实图搜采集业务效果仍待验收。
|
||||
|
||||
- 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。
|
||||
- `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。
|
||||
@@ -875,7 +877,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## Android 本设备最后一批重购(#367)
|
||||
|
||||
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);未合并 main、未安装或发布,真机批量采购仍待独立授权验收。
|
||||
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);2026-10-09 已合并 main `e26743c` 并安装集成APK,真机批量采购仍待独立授权验收。
|
||||
|
||||
- 采购页搜索栏的“回填”入口替换为“重购”。原回填弹窗、Service 动作和实现保留但不再由该按钮触发;原单条重试、继续采购不改。重购使用原任务的 `/reset`,保留 CG 编号,不使用创建替代任务的 `/retry`。
|
||||
- 读取本设备近30天全部状态、不继承页面编号/状态筛选;按创建时间倒序、同刻任务ID倒序,从最新记录逐项比较相邻时间,不超过60秒归入同一推算批次。跨50条分页去重,最多200页,结束复核首页;头部或总量变化、日期/页结构异常、查询截断无法证明完整时不启动,不拿部分列表执行。批次时间边界使用独享API客户端的 HTTP Date,不使用手机时钟,缺少有效 Date 拒绝;此轻量方案不保证任意并发删除/归属变动的数据库快照。
|
||||
@@ -885,3 +887,49 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- Service 独立串行后台调度观察,真正领取、执行、上传仍由原执行器和租约/任务锁负责。终态且执行锁、活跃记录和Outbox均收尾后才继续。检测新采集或采购工作停止剩余,本轮当前task本身不算新工作。
|
||||
- 页面和通知栏“停止重购”只停止后续,已提交/执行的任务沿用原流程;切Tab、切PDD、界面重建不中断。单条单调时钟等待上限15分钟,超时只停止编排,不取消任务、不重置或再次下单。进程死亡/服务重建不恢复整轮,原单任务恢复不改。
|
||||
- 本轮摘要与最小attempt关联仅在内存,不新增地址/树采集或数据库。接口、服务端、Web、权限及 #364 诊断契约不变。自动化回归和APK构建通过不代表真机页面/批量下单已验收。
|
||||
|
||||
|
||||
## 采购页面一次性交接与导航稳定等待(#373 v4)
|
||||
|
||||
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`;2026-10-09 与 #374 依次合并并推送 main,集成提交 `555a954605a83c12457ed1ada86eb732fe3bb023`。集成APK仍标识 `0.9.69-373-fix1 / code82`,须以哈希区分;未安装或发布。此节不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
|
||||
|
||||
- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。
|
||||
- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。
|
||||
- 采集、图搜、当前页采集、回填、恢复执行、导航诊断及已知主动离页操作都会使原交接不可用;服务/无障碍重建不恢复。复用仍要求新鲜PDD商品结构,读取后再次核对同一次已消费凭证的期限、实例、前台与代次,避免慢Binder读取期间过期。人工在同一PDD包内切页可能没有可观察前台变化,不能声称完全识别。
|
||||
- 不符合复用资格时打开本任务链接,沿用直达NEW_TASK|CLEAR_TASK及异常回退浏览器。等待从该次打开开始,不是给整个重购批次加固定间隔。执行器取得实际ACCEPTED路径;未知路径不当作直达。
|
||||
- 浏览器路径先观察到受支持浏览器,再允许PDD商品结构进入就绪判断,浏览器出现前的旧PDD帧不放行。直达接受后至少3秒缓冲;浏览器返回PDD后至少3秒缓冲。商品结构还需连续稳定至少1秒、至少3次读取,缓冲与稳定时间可以重叠。就绪后立即继续,不无条件等30秒。
|
||||
- 商品稳定特征只在内存比较标题和规格入口/面板结构,不以销量、价格或滚动拼单变化为依据。非PDD、空窗口、缺少商品证据或结构变化会打断稳定计时;登录、验证码、风控和无效商品保持明确失败。
|
||||
- 导航总预算为单调时钟30秒、最多300轮、轮间100ms;耗时系统读取可能超过预算,但返回后检查时限,不再继续点击。浏览器入口重试从点击调用返回后至少间隔1秒;原生点击前在新鲜节点重新校验浏览器与剩余预算。超时返回PDD_DETAIL_ENTRY_FAILED,不继续规格、地址或创建订单步骤。
|
||||
- 用户明确拒绝新增分享/复制链接操作,并接受本版仅解决跨任务无条件复用与导航抢跑。**稳定商品页不是goodsId身份核验;PDD忽略深链而稳定停在旧页、或无可观察变化的同包人工换页,仍可能无法识别。** 不得将任务携带goodsId当作页面回读,不能用本版宣称绝对排除错误商品。
|
||||
- 不改服务端/API线协议/数据库、采集器、规格选择、地址与下单逻辑、重购批次发现/停止逻辑;不涉及任何付款动作。仅增加Android对既有结果响应status/replayed的本地解析(replayed=false在服务端可省略)。普通流程不新增原始树、截图、分享URL或页面文本落盘。
|
||||
|
||||
## 下单前最终规格复核(#374 v3)
|
||||
|
||||
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
|
||||
|
||||
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
|
||||
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
|
||||
- 复用既有数量输入有界祖先的完整面板形态,但保留真实祖先关系与边界双重归属;滚动列表不等于整个弹层,摘要可位于列表上方/兄弟区域。范围内重新确认面板结构,不将全屏/root 当面板,不用仅坐标重叠的面板外文字。完整范围不可靠时禁用摘要,只使用可独立确认的有界规格滚动区域内选项。
|
||||
- 最终确认专用解析参数默认 false,普通采集及既有调用不变。仅该路径保留同名独立物理选项;沿用原 optionBlock selected/checked 聚合,并合并同值、同矩形、真实祖先链的同一选项,不吞并同名兄弟候选。同一维度多组统一检查。
|
||||
- 只检查下发非空维度,先拒绝同维度明确选中非目标、目标不可用、不同实际候选归一到同一目标或已知候选组合歧义;随后当前面板内规范化完全相等且 selected/checked 的目标可证明该维度。没有选中证明但也没有冲突时才用可信摘要,即使其他未选中候选仍可见也允许摘要补证,不使用历史 attempt 证明。
|
||||
- 摘要仅剥离开头已识别的已选前缀和紧随的一个可选冒号,保留正文标点与内容。无前缀摘要仅沿用已有 quantity/header 结构识别且须属于完整面板。当前双维证据只覆盖“颜色 空格 尺码”,逗号分隔、反序、数量后缀或未知格式不猜测支持;仅一个维度时整段须等于该值,两维均空不作为证明。
|
||||
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
|
||||
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
|
||||
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
|
||||
|
||||
## 采购改地址前复核与提交提示检查(#376 v3)
|
||||
|
||||
实现绑定工单分支 `fix/376-pre-address-spec-verification` 提交 `9c7357609cee7c73b57edd030136ed12ef9485b6`(2026-10-09),尚未合并 main 或发布;此规则仅适用于包含 #376 的 Android 构建,旧版 #374 行为不因此改变。没有 Server/Web、业务库迁移或 API 边界协议变化。
|
||||
|
||||
- 改地址前,verifySummary 在每个目标维度和既有重定位滑动后的当前面板中检查未选提示。面板摘要的“请选择/請選擇”或唯一提交按钮的选择提示导致 PURCHASE_SPEC_SELECTION_UNCONFIRMED、reason=selection_prompt,停止后续地址动作;无唯一提交按钮时仍可检查摘要,不增加改地址前缺少按钮的门禁。
|
||||
- 本次选择历史证明仅用于既有可见性缺口;目标当前可见、可选却未选中,同维无其他选中项且没有已选摘要时,不再由历史证明路径放行,reason=target_visible_unselected。目标不可见时保留原历史证明行为;不暴露选中属性且无摘要的布局可能提前失败。此前规格选择点击、数量设置、改地址前价格/数量复核不变。
|
||||
- 修改地址后的 finalConfirmation 不再比较颜色/尺码值,不要求选中证据,也不核对当前数量;保留地址、提交目标唯一性和单价区间检查。摘要未选提示在此只记录,不拦截。唯一提交按钮的完整文字去空白后匹配 `^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$` 才新增拒绝 PURCHASE_SPEC_NOT_MATCHED、finalSpec;stage=selection_prompt。
|
||||
- 按钮来源沿用原面板底部行 row 和点击文字节点 chosen;提示只组合该行内可见、自身带文字且没有带文字子孙、最近可点击祖先恰为 row 的节点,按 top/left 排序,不重复拼父子汇总、不跨独立按钮/摘要/规格列表或不可见节点。实际点击目标、行/叶并列选择和支付排除保持不变;enabled=true 不是已选规格的证明。
|
||||
- finalConfirmation 位于 beforeOrderSubmit 之前,拒绝时不写提交边界、不点击,普通失败可让重购继续。submitOrderOnce 重新取屏后的同类提示只作兜底,零点击但边界已写入,仍为 order_result_unknown,重购停止等待人工核对;不移动持久化边界、不增加重试或付款。
|
||||
- 本地 final_confirmation_json 新记录以 targetQuantity 表示下发数量,追加 observedQuantity(唯一可见可用数字数量输入,否则 null)、selectedSummaryPresent 与 summaryPromptPresent。仅记录数字/布尔观察值,不新增页面文字或个人信息;不用于拦截、不上传,旧记录不改写。
|
||||
- 用户明确接受剩余风险:修改地址后规格被换成其他值,或数量重置,但按钮仍正常时会放行;价格与地址检查不能替代规格/数量一致性。观察值仅供事后核对,不证明采购结果正确。缺少提示也不构成 PDD 已选正确的正向证明。
|
||||
- 不新增分享、OCR/VLM、滑动取证、自动重新选规格或付款;合成测试与 APK 构建不代表真实订单验证。安装、真实采购、合并和发布须按相应授权执行。
|
||||
|
||||
验证:19 个相关套件共418项通过,0失败/错误/跳过;Debug APK与release单测源码编译通过。执行器测试模拟提交边界回调,并验证本地JSON序列化;未执行真实SQLite/服务端边界端到端或真实采购验证。不宣称全量Android测试通过(既有图搜返回忙循环用例不在本次回归范围)。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44
|
||||
synchronized_at: 2026-10-08T03:25:33Z
|
||||
wiki_revision: 6b370764320d1f5148fbc31f0ef6ce999370173d
|
||||
synchronized_at: 2026-10-09T02:06:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -76,7 +76,7 @@ Agent 0.9.13 起,颜色发现阶段在本地 `goauto_diagnostics.db` 的 `agen
|
||||
|
||||
记录只包含颜色行数、各行值数量、可点击颜色数、不可点击候选数、首次颜色点击前的颜色/尺码选中数、是否存在“已选”摘要、横向滑动次数、终止原因和耗时,不保存规格文案、价格、摘要原文、坐标、链接、goods_id、控件树、XML 或截图。
|
||||
|
||||
只对 Debug APK 使用以下读取流程:
|
||||
以下仅为 v4 之前纯结构化诊断库的历史读取方式。v4 及之后包含采购失败私有现场,不再按此导出整库;请使用下方“Android 颜色发现诊断 v5”中的定向只读方式:
|
||||
|
||||
```powershell
|
||||
adb shell run-as cn.ilapage.goauto.agent ls databases
|
||||
@@ -136,3 +136,25 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
|
||||
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
|
||||
- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。
|
||||
- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。
|
||||
|
||||
## Android 颜色发现诊断 v5(#370 第一阶段)
|
||||
|
||||
实现绑定 `9e5f09a`(工单分支),Debug 标识 `0.9.69-370-diag1`,尚未合并 main。本阶段只补诊断和测试夹具,不改变遍历、停止条件、采集完成状态或采购探测决策;不能据此宣称竖向漏采已修复。
|
||||
|
||||
`COLOR_DISCOVERY` 的结束聚合新增三个可空列:
|
||||
|
||||
- `color_vertical_swipe_count`:本次颜色循环中成功派发的 UP 次数,排除归顶及尺码阶段;不证明视口实际移动。
|
||||
- `color_trailing_empty_read_count`:终止前连续解析不到颜色的外层发现轮数;每轮只记一次,点击与采价重读不计,非空轮清零,初始空为 1。
|
||||
- `color_horizontal_incomplete`:1 表示已观察到同一行签名变化后,必要横向探索因失败或预算耗尽仍未完成;0 表示当前已观察行无需横扫或取得连续稳定证据;NULL 表示无法确认,包括重排、空解析、缺少横向容器或仅成功派发但没有移动证据。同 seed 的未知回访不能清除旧的 1,可靠完成的 0 可以清除。0 不代表整个商品已采全,签名变化也不是物理滑动成功的独立证明。
|
||||
|
||||
只保存计数、布尔/NULL 和固定原因码,不保存标签、价格、坐标、原始控件树、截图或个人信息。每行累计数仍不是逐屏轨迹;缺少日志不证明没执行,错误/超时提前退出可能没有结束聚合。现有 50 条/7 天留存不变。
|
||||
|
||||
本地 SQLite v4→v5 只补三列;旧行新值保持 NULL,不删除旧诊断或采购失败快照/队列/context。旧程序不保证能降级打开 v5,不能靠卸载清数据回退。
|
||||
|
||||
`goauto_diagnostics.db` 从 v4 起还含采购失败私有现场,不能按早期“纯结构化库”的假设导出或上传整库。已授权的 Debug 设备有 sqlite3 时可只查询所需聚合字段;无 sqlite3 时只使用已确认的内存读取方式,避免整库落盘,不放宽应用权限。示例查询(须替换设备,且实际存在 sqlite3):
|
||||
|
||||
```powershell
|
||||
adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,reason,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic WHERE stage='COLOR_DISCOVERY' ORDER BY id DESC LIMIT 1;"
|
||||
```
|
||||
|
||||
排查须绑定同一设备、Agent/PDD 版本、任务与规则快照,并对照同次 App 布局。网页竖排与 App 横排不是同一复现场景;当前横排只能做横向兼容性回归。真机测试结果及待验证项以 #370 工单为准。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 6ca0b35cb1e8058b2930a9a01029822af8b571d2
|
||||
synchronized_at: 2026-10-08T07:10:52Z
|
||||
wiki_revision: 3f69f2e39675752dfd43dc38de41cb7defb74a42
|
||||
synchronized_at: 2026-10-09T07:58:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -312,7 +312,7 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
|
||||
## 采购失败现场诊断部署与保留(#364)
|
||||
|
||||
实现绑定 `4581ee5`;本节为新版本部署要求,不表示线上已迁移/发布。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
|
||||
实现绑定 `4581ee5`;2026-10-09 已随 main `e26743c` 完成线上迁移及发布,见下方发布记录。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
|
||||
|
||||
1. 先取得目标环境的追加迁移及发布授权,核对唯一待执行版本 `1791400000000_purchase_failure_snapshot.go`,建立独立私有诊断表,再启动配套Server/Web。专用摘要/ZIP下载注册到既有启动权限对账,只允许管理员;对账会维护完整API目录及采购员既有权限,不是只写两条接口。
|
||||
2. Server接收与权限验证就绪后再安装新Agent。旧Server不支持新诊断接口;不要把“本机保存成功”当作“已上传”。仅合并main不启动服务、不执行线上迁移、不自动安装APK。
|
||||
@@ -320,3 +320,55 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。
|
||||
5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。
|
||||
6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。
|
||||
|
||||
|
||||
## 2026-10-08 换机后本地 Supervisor 联调配置(#364 后续运行准备)
|
||||
|
||||
本节是当前电脑的配置,优先于上文历史 D:/supervisor、3307 和 main-runtime 路径;不改变线上拓扑。
|
||||
|
||||
- Supervisor 位于 `D:/supervisord`,主配置 `supervisord.conf` 通过 `[include] files=programs/goauto.conf` 引入两个实例:`goauto-admin-api` 和 `goauto-admin-ui`。两者 `autostart=false`、`autorestart=true`,添加/重读配置不等于批准启动所有服务;只对目标实例操作,不重启整个Supervisor。
|
||||
- 独立运行目录 `D:/OPC/goauto-runtime` 干净地固定在已合并main的 `e450ac28989de6827328b411e4bdc050dded9ab3`,用detached HEAD避免与开发分支混淆。根仓库 `D:/OPC/goauto` 仍保留用户旧分支和未提交资料,不在其中切main或覆盖。
|
||||
- 两实例读取 `D:/OPC/goauto/config.yaml`;本地MySQL为127.0.0.1:3308/goauto,API端口8010,Web端口9527。后端调用运行目录的 `scripts/start-server.ps1 -ConfigPath D:/OPC/goauto/config.yaml -SkipMigration`,前端调用 `scripts/start-web.ps1 -ConfigPath D:/OPC/goauto/config.yaml`。后端仍会校验/确保数据库存在并执行既有启动权限对账;SkipMigration不是只读启动。
|
||||
- 当前电脑默认go为1.24且GOTOOLCHAIN=local,继承的GOSUMDB=off又会阻止自动下载1.26.5;仅后端实例的PowerShell命令把已安装 `C:/Users/Qiu/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.5.windows-amd64/bin` 前置到进程PATH,并设置GOTOOLCHAIN=local。已用该环境构建验证;未更改全局环境、校验策略或项目go.mod。此绝对路径是本机配置,其他电脑须核实已安装工具链,不照抄。
|
||||
- 前端依赖属于运行目录自身,按锁文件安装,不链接其他worktree的node_modules。若旧残留依赖出现ERR_MODULE_NOT_FOUND且普通安装未补齐,先用 `node -e "import('vite')"` 复现,再在本目录按锁文件重新安装;不通过修改业务代码或升级依赖绕过。
|
||||
- 手动启动顺序为API就绪后Web;Web原脚本最多等待API健康60秒,并将/api同源代理到本机8010。浏览器本机入口 `http://127.0.0.1:9527/`,Agent使用电脑实际局域网IP的8010端口,不把127.0.0.1配置给手机。
|
||||
- 日志为 `D:/supervisord/goauto-admin-api.log`、`D:/supervisord/goauto-admin-ui.log`。检查依赖、端口和实际子进程环境;不能只因Supervisor显示Running就宣称HTTP就绪。
|
||||
- 新库预置SYB每小时同步与Yeeke同步status=2(启用);蝦皮规格自动匹配、异常规格AI解析status=1(关闭)。本轮只读检查,未改任务状态;用户手动启动API后既有调度会运行,真实外部访问须结合本地凭据/配置确认,不能把本地环境当作自动隔离的沙箱。
|
||||
- 配置动作未新增迁移、未部署线上、未安装APK;用户随后确认亲自点击了启动。健康/页面验证记录在工单,不将健康检查冒充登录、诊断下载或真机端到端验收。
|
||||
|
||||
|
||||
## 2026-10-09 集成发布与 Android 签名连续性(#364/#365/#366/#367/#368/#370)
|
||||
|
||||
- 集成源码 main `e26743c817275137f7dc8a3659461d552371b9e3`。122.228.200.167 当前发布目录 `/home/goauto/releases/20261009-e26743c-integrated`,旧目录 `/home/goauto/releases/20261007-0138da8-359` 保留供代码回退。用户明确要求线上有采集/采购执行时不得重启;迁移前及切换前相关运行计数均为0,没有取消、重置或发起业务。
|
||||
- 已授权追加迁移 `1791400000000_purchase_failure_snapshot.go`、启动权限对账及发布。私有备份 `/home/goauto/backups/20261009-integrated/database.sql` 目录0700/文件0600;只新增诊断表,回读LONGTEXT/LONGBLOB及attempt唯一索引通过。定时任务启停、Cron与参数摘要前后一致。
|
||||
- Server SHA256 `ae7e479e94db0485661665306d0d0306ca5c3cd4019694991c29b64d113eb637`,运行进程二进制一致;新Web入口SHA256 `242158568fb3ee9c51e2e16574abf0a1b6b5b692ae167ec78bcb957cdf5e7434`。根入口、index、login、syb-products入口、10项JS/CSS、健康与验证码通过。Nginx实际读取用户可读性切换前确认;保留旧hashed资源与config/static/temp/var。没有更改Nginx配置;未认证诊断接口401、现有非管理员凭据403。
|
||||
- 本次自动化验证为Android 274项受影响测试、Server任务/采购/商品/规格/权限/中间件/迁移相关包、Web 6项模拟API页面测试及三端构建。未执行真实采购或付款;管理员真实诊断下载及完整失败现场上传仍待业务验收。#366仅环节②,#370仅第一阶段诊断,不能视为剩余根因修复已完成。
|
||||
- Android集成APK保持 `0.9.69-370-diag1 / versionCode 82`,不能只凭版本号区分旧包;本次最终APK SHA256 `68d7caf9ae7ee5808783056f4f1591266746197b520048fa64c89365723edef7`。2026-10-09已覆盖安装到用户指定手机,安装后文件哈希一致、配置及设备身份前后摘要一致、进程启动存在,没有卸载或清数据。
|
||||
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
|
||||
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
|
||||
|
||||
|
||||
## Android 商品导航诊断(#373,诊断阶段)
|
||||
|
||||
- 分支实现 `2c58d58`,调试包 `0.9.69-373-nav-diag3`;尚未合并 main,不代表旧商品页复用或旧页排除已修复。正式采购打开路径保持原样,新增 `PRODUCT_NAVIGATION` 结构诊断至既有 SQLite `agent_diagnostic`,区分 DIRECT/BROWSER 的 ACCEPTED/REJECTED/EXCEPTION,仅记录异常类名及耗时,沿用任务、attempt、device、phase、rule hash 关联和原50条/7天保留规则,无数据库升级。ACCEPTED只表示startActivity返回,不表示目标商品已加载。
|
||||
- 仅debug包包含 `cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver`,要求发送者具有 `android.permission.DUMP`(ADB shell/system),release清单不含该入口。输入仅数字goodsId及direct/browser,不接受任意URL、脚本或购买动作。必须先获得指定设备覆盖安装及只打开商品页的授权,确认无业务任务运行;保留原签名用install -r,禁止为安装清数据或卸载。
|
||||
- Agent主界面启动前台服务且无障碍就绪后,通过ADB执行 `adb -s <设备地址> shell am broadcast -n cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver --es goodsId <数字商品ID> --es mode direct`;浏览器对照使用 `--es mode browser`。忙碌/服务未就绪时拒绝、不排队,不能用取消或重置业务任务强行取得设备。
|
||||
- 探针占用现有设备互斥锁,预算20秒/最多201轮;耗时Binder读取可能越过观察预算,但其后不再派发导航/点击,不能声称能强制中断系统读取。服务或无障碍实例失效、线程中断、登录/风控/验证码时停止。只打开商品链接,在浏览器中最多尝试3次唯一的精确“打开拼多多APP”/“打开拼多多 App”且节点自身可点击的入口;实际派发前再次校验包、窗口、节点与存活条件,无祖先/坐标/手势兜底。PDD内只读,不调用采购执行器、不进入规格选择/改地址/下单/付款,不创建/重试服务器任务。
|
||||
- 临时探针输出通过 `adb -s <设备地址> logcat -d -s GoAutoNavProbe:I '*:S'` 读取;只包含随机runId、路径/固定结果、相对毫秒、节点计数、窗口id、布尔证据及仅在内存比较所得标题same/different/unknown,不保存标题/哈希/URL/页面原文/原始树/截图,不写业务数据库。别用logcat -t的全局最后几行冒充完整诊断;若缓冲已覆盖须如实记录。禁止导出完整无过滤系统日志。
|
||||
- `legacy_would_accept`模拟现有两次商品结构命中,始终标注identityVerified=false;窗口变化、标题变化、商品结构成立都不是目标goodsId证明。shell触发探针与后台任务的系统启动许可可能不同;人工指定browser只验证浏览器路径,不证明当时自动回退的异常原因。正常/失败对照及真实后台时序仍须以任务绑定诊断和人工确认核验。
|
||||
- 自动化验证应包含debug专用探针单测与 `:app:compileReleaseUnitTestKotlin`;探针测试放 `src/testDebug`,不得在共享 `src/test` 引用debug-only类。构建使用原私有签名,安装后回读包版本和APK哈希,并比较设置/设备身份摘要。
|
||||
|
||||
## #373 v4 修复版构建与验证(2026-10-09)
|
||||
|
||||
- 源码分支 `fix/373-navigation-diagnostics`,实现提交 `dbf9a5f4606529189e57b6c266449070d5001340`。APK版本 `0.9.69-373-fix1 / code82`,与前一导航诊断版不同;未安装修复版、未合并main、未发布线上,不需要Admin迁移或服务重启。
|
||||
- APK SHA256 `171f4b12395e2940ce873b62ea394799f521d1a0997f6aaf7582a123c24795e8`;仍使用用户原签名,证书SHA256 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。后续经安装授权用install -r保留数据,不能为覆盖擅自卸载。相同versionCode不能证明文件相同,应核对完整版本和哈希。
|
||||
- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。
|
||||
- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。
|
||||
- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。
|
||||
|
||||
## #373 + #374 合并 main 与集成构建(2026-10-09)
|
||||
|
||||
- 按用户授权的顺序先合并 #373(`1e924fa41195281a3223003a69c8c16ae37f8aa5`),再合并 #374(`555a954605a83c12457ed1ada86eb732fe3bb023`),保留两单提交历史;main已推送。源码无冲突,三份Wiki镜像选择包含两单内容的较新已导出版本,未手改规则正文解决冲突。
|
||||
- 集成验证25套件478项通过,0失败/错误/跳过;compileReleaseUnitTestKotlin与assembleDebug通过。不是全量单测结论;#374记录的未修改图搜返回测试忙循环未在本次重跑。未进行真机采购。
|
||||
- 集成APK位于本机 `D:/OPC/goauto-worktrees/release-373-374/android/app/build/outputs/apk/debug/app-debug.apk`,版本仍为 `0.9.69-373-fix1 / code82`,**已同时包含#374,不能只靠版本文字区分前一#373单独包**。SHA256 `6e2d0e0ed1eb943b3f168bb16d628d2c2bb4ce3b7c7c8b9e567dcdf8259150f8`;apksigner校验通过,沿用原签名证书 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。
|
||||
- 本次仅合并、验证和推送,未覆盖安装、未执行真实采购/付款、未发布或重启服务;不需要业务库迁移。手机仍使用此前安装版本。后续装机须另行授权,保留原签名及用户数据。
|
||||
|
||||
@@ -2,6 +2,7 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -15,8 +16,8 @@ import (
|
||||
)
|
||||
|
||||
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
|
||||
// writes an association when the task was a complete collection, the frozen
|
||||
// original association is unchanged, and the target product is active. Manual
|
||||
// writes an association when the collection is complete or missing only optional
|
||||
// metadata, the frozen original association is unchanged, and the target product is active. Manual
|
||||
// changes made while the Agent was running win by the CAS predicate.
|
||||
// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。
|
||||
// 只有确实写入时才值得触发后续的规格匹配(#287)。
|
||||
@@ -32,7 +33,7 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
|
||||
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
||||
return autoLinkedImageSearch{}, internalError(err)
|
||||
}
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || !imageSearchResultAllowsAutoLink(task) || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
return autoLinkedImageSearch{}, nil
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(task)
|
||||
@@ -94,6 +95,31 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// #368: Keep completed behavior unchanged. A partial result is eligible only
|
||||
// with explicit, nonempty evidence that every missing field is optional.
|
||||
// Unknown/malformed missing data must not silently widen this whitelist.
|
||||
func imageSearchResultAllowsAutoLink(task models.CollectionTask) bool {
|
||||
if task.Status == models.TaskStatusCompleted {
|
||||
return true
|
||||
}
|
||||
if task.Status != models.TaskStatusCompletedPartial || task.MissingJSON == nil {
|
||||
return false
|
||||
}
|
||||
var missing []string
|
||||
if err := json.Unmarshal([]byte(*task.MissingJSON), &missing); err != nil || len(missing) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, field := range missing {
|
||||
switch field {
|
||||
case "reviewCount", "salesText", "shopName":
|
||||
// Optional descriptive metadata; never colors, sizes or prices.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the
|
||||
// details the image-search task just linked (#287).
|
||||
//
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This fixture leaves the hook uncalled so tests can change the submitted
|
||||
// status, missing fields and association independently of the assertions.
|
||||
func seedPartialImageSearch(t *testing.T, db *gorm.DB, deviceID *uint64) (models.CollectionTask, models.ShopeeProduct) {
|
||||
t.Helper()
|
||||
record := createTask(t, db, deviceID)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "active").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{
|
||||
ShopeeItemID: "test-item", Title: "test", Currency: "TWD",
|
||||
SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"M","source":"import"}]}]`,
|
||||
}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syb := models.SYBProduct{
|
||||
OrderCode: "TEST-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID,
|
||||
ShopeeProductID: &shopee.ID, TargetColor: "黑色", TargetSize: "M",
|
||||
Quantity: 1, UnitPriceCent: 1000, ParseStatus: "success", RawJSON: "{}",
|
||||
}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := ImageSearchSnapshot{
|
||||
ShopeeProductID: shopee.ID, RepresentativeSYBProductID: syb.ID, SYBProductIDs: []uint64{syb.ID},
|
||||
ReferenceCurrency: "TWD", ReferencePriceCent: 1000, MaxPriceRatio: 3, PriceGuardSkipped: true,
|
||||
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
|
||||
}
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&record).Updates(map[string]any{"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": string(raw)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return record, shopee
|
||||
}
|
||||
|
||||
func TestAutoLinkImageSearchPartialMissingWhitelist(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, status, missing string
|
||||
nilMissing, want bool
|
||||
}{
|
||||
{name: "completed_empty", status: models.TaskStatusCompleted, missing: "[]", want: true},
|
||||
{name: "completed_nil", status: models.TaskStatusCompleted, nilMissing: true, want: true},
|
||||
{name: "nil", nilMissing: true},
|
||||
{name: "blank"},
|
||||
{name: "null", missing: "null"},
|
||||
{name: "empty", missing: "[]"},
|
||||
{name: "malformed", missing: `["reviewCount"`},
|
||||
{name: "object", missing: `{ "reviewCount": true }`},
|
||||
{name: "scalar", missing: `"reviewCount"`},
|
||||
{name: "number_entry", missing: `["reviewCount",1]`},
|
||||
{name: "null_entry", missing: `["reviewCount",null]`},
|
||||
{name: "blank_entry", missing: `[""]`},
|
||||
{name: "unknown", missing: `["reviewCount","futureField"]`},
|
||||
{name: "case_mismatch", missing: `["ReviewCount"]`},
|
||||
{name: "whitespace", missing: `[" reviewCount "]`},
|
||||
{name: "color", missing: `["reviewCount","color"]`},
|
||||
{name: "size", missing: `["size"]`},
|
||||
{name: "price", missing: `["price"]`},
|
||||
{name: "color_price", missing: `["reviewCount","price:黑色"]`},
|
||||
{name: "unsupported_dimension", missing: `["unsupportedDimension:款式"]`},
|
||||
{name: "invalid_dimension", missing: `["spec_dimension_invalid:size"]`},
|
||||
{name: "pending", status: models.TaskStatusPending, missing: `["reviewCount"]`},
|
||||
{name: "failed", status: models.TaskStatusFailed, missing: `["reviewCount"]`},
|
||||
{name: "duplicate_optional", missing: `["reviewCount","reviewCount"]`, want: true},
|
||||
}
|
||||
fields := []string{"reviewCount", "salesText", "shopName"}
|
||||
for mask := 1; mask < 8; mask++ {
|
||||
var missing []string
|
||||
for i, field := range fields {
|
||||
if mask&(1<<i) != 0 {
|
||||
missing = append(missing, field)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(missing)
|
||||
cases = append(cases, struct {
|
||||
name, status, missing string
|
||||
nilMissing, want bool
|
||||
}{name: fmt.Sprintf("allowed_%d", mask), missing: string(raw), want: true})
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
record, shopee := seedPartialImageSearch(t, db, nil)
|
||||
status := tc.status
|
||||
if status == "" {
|
||||
status = models.TaskStatusCompletedPartial
|
||||
}
|
||||
var missing *string
|
||||
if !tc.nilMissing {
|
||||
missing = &tc.missing
|
||||
}
|
||||
if err := record.SetStatus(status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&record).Updates(map[string]any{"status": status, "missing_json": missing, "active_slot": record.ActiveSlot, "device_run_slot": record.DeviceRunSlot}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := NewService(db).autoLinkImageSearch(context.Background(), record.ID)
|
||||
if err != nil || linked.Linked != tc.want {
|
||||
t.Fatalf("Linked=%v want=%v error=%v", linked.Linked, tc.want, err)
|
||||
}
|
||||
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shopee.ImageSearchLinked != tc.want || (shopee.PDDProductID != nil) != tc.want {
|
||||
t.Fatalf("association/marker mismatch: %+v", shopee)
|
||||
}
|
||||
if tc.want && (*shopee.PDDProductID != *record.PDDProductID || len(linked.SYBProductIDs) != 1) {
|
||||
t.Fatal("linked target or downstream SYB identifiers lost")
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Status != status || !reflect.DeepEqual(record.MissingJSON, missing) {
|
||||
t.Fatal("eligibility must not rewrite collection status or missing fields")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitImageSearchPartialLinkAndMatch(t *testing.T) {
|
||||
for _, scenario := range []string{"optional_partial", "completed", "critical_missing", "admin_source", "current_page_source", "disabled_pdd", "association_changed", "snapshot_missing", "ai_unavailable", "request_cancelled_during_match"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
requestContext, cancelRequest := context.WithCancel(context.Background())
|
||||
defer cancelRequest()
|
||||
var syncCalls atomic.Int32
|
||||
erp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncCalls.Add(1)
|
||||
if scenario == "request_cancelled_during_match" {
|
||||
// Cancel only after the real post-result matching hook has
|
||||
// started. Its detached context must still persist mappings.
|
||||
cancelRequest()
|
||||
}
|
||||
if scenario == "ai_unavailable" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"color":["黑色"],"size":["M"]}}`)
|
||||
}))
|
||||
defer erp.Close()
|
||||
t.Setenv("GOAUTO_ERPGO_BASE_URL", erp.URL)
|
||||
t.Setenv("GOAUTO_ERPGO_APIKEY", "synthetic-test-key")
|
||||
db := openTaskDatabase(t)
|
||||
device, token := registerTaskDevice(t, db, "partial-image-search")
|
||||
record, shopee := seedPartialImageSearch(t, db, &device.ID)
|
||||
service := newTaskService(db)
|
||||
// Execution is outside this test's scope; submit through the real
|
||||
// authenticated running-owner path with a persisted image-search snapshot.
|
||||
if err := db.Model(&record).Update("status", models.TaskStatusRunning).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
title, shop, sales, reviews := "商品", "店铺", "已拼100件", int64(12)
|
||||
request := ResultRequest{
|
||||
RequestID: uuid.NewString(), Status: models.TaskStatusCompletedPartial,
|
||||
Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot, Title: &title, ShopName: &shop, SalesText: &sales},
|
||||
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"M"}}},
|
||||
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1000}},
|
||||
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色", "size": "M"}, PriceCent: 1000, Available: true}},
|
||||
Missing: []string{"reviewCount"},
|
||||
}
|
||||
wantLinked, wantMapped := true, true
|
||||
var unchangedPDD *uint64
|
||||
switch scenario {
|
||||
case "completed":
|
||||
request.Status, request.Missing, request.Product.ReviewCount = models.TaskStatusCompleted, []string{}, &reviews
|
||||
case "critical_missing":
|
||||
request.Missing = append(request.Missing, "size")
|
||||
wantLinked, wantMapped = false, false
|
||||
case "admin_source", "current_page_source":
|
||||
source := models.CollectionTaskSourceAdmin
|
||||
if scenario == "current_page_source" {
|
||||
source = models.CollectionTaskSourceAgentCurrentPage
|
||||
}
|
||||
if err := db.Model(&record).Update("source", source).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "disabled_pdd":
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "disabled").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "association_changed":
|
||||
manual := seedAutoLinkPDD(t, db, "manual-pdd")
|
||||
unchangedPDD = &manual.ID
|
||||
if err := db.Model(&shopee).Update("pdd_product_id", manual.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "snapshot_missing":
|
||||
if err := db.Model(&record).Update("image_search_snapshot", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "ai_unavailable":
|
||||
// No exact match and no configured AI provider: the existing
|
||||
// matching path must not invent mappings or undo successful collection.
|
||||
request.Dimensions[0].Values = []string{"白色"}
|
||||
request.Dimensions[1].Values = []string{"L"}
|
||||
request.ColorPrices[0].Color = "白色"
|
||||
request.SKUs[0].Specs = map[string]string{"color": "白色", "size": "L"}
|
||||
wantMapped = false
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
ctx := context.Background()
|
||||
if attempt == 0 {
|
||||
ctx = requestContext
|
||||
}
|
||||
detail, err := service.SubmitResult(ctx, record.ID, request, token)
|
||||
if scenario == "request_cancelled_during_match" && attempt == 0 {
|
||||
// Detail still uses the original request context. Preserve that
|
||||
// existing return behavior, then inspect the committed state
|
||||
// independently; do not pretend the disconnected client got OK.
|
||||
if requestContext.Err() != context.Canceled || err == nil {
|
||||
t.Fatalf("expected cancelled response read, got %v", err)
|
||||
}
|
||||
detail, err = service.Detail(context.Background(), record.ID)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("submit attempt %d: %v", attempt, err)
|
||||
}
|
||||
if detail.Task.Status != request.Status || !reflect.DeepEqual(detail.Missing, request.Missing) || detail.Replayed != (attempt == 1) {
|
||||
t.Fatalf("submission/replay changed result: status=%s missing=%v replayed=%v", detail.Task.Status, detail.Missing, detail.Replayed)
|
||||
}
|
||||
if len(detail.SKUs) != 1 || len(detail.Dimensions) != 2 {
|
||||
t.Fatal("result children missing or duplicated")
|
||||
}
|
||||
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shopee.ImageSearchLinked != wantLinked {
|
||||
t.Fatalf("image search marker=%v", shopee.ImageSearchLinked)
|
||||
}
|
||||
wantPDD := unchangedPDD
|
||||
if wantLinked {
|
||||
wantPDD = record.PDDProductID
|
||||
}
|
||||
if !reflect.DeepEqual(shopee.PDDProductID, wantPDD) {
|
||||
t.Fatal("wrong association or concurrent change overwritten")
|
||||
}
|
||||
wantCalls := int32(0)
|
||||
if wantLinked {
|
||||
wantCalls = 1
|
||||
}
|
||||
if syncCalls.Load() != wantCalls {
|
||||
t.Fatalf("matching hook calls=%d want=%d", syncCalls.Load(), wantCalls)
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("unexpected archive dimensions: %d", len(specs))
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
for _, value := range dimension.Values {
|
||||
if wantMapped {
|
||||
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed || value.Mapping.PDDValue != value.Name {
|
||||
t.Fatalf("matching not persisted: %+v", value)
|
||||
}
|
||||
} else if value.Mapping != nil {
|
||||
t.Fatalf("unexpected mapping: %+v", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if scenario == "optional_partial" {
|
||||
// A second result for the same image carries the same original
|
||||
// association snapshot. The first committed link wins; no fallback
|
||||
// to another result or a second matching pass is introduced.
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := createTask(t, db, &device.ID)
|
||||
if err := db.Model(&second).Updates(map[string]any{
|
||||
"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": record.ImageSearchSnapshot,
|
||||
"status": models.TaskStatusRunning,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.RequestID, request.Product.PDDGoodsID = uuid.NewString(), second.GoodsIDSnapshot
|
||||
if _, err := service.SubmitResult(context.Background(), second.ID, request, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(shopee.PDDProductID, record.PDDProductID) || syncCalls.Load() != 1 {
|
||||
t.Fatal("competing image-search result replaced association or retriggered matching")
|
||||
}
|
||||
}
|
||||
var purchases int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Count(&purchases).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if purchases != 0 {
|
||||
t.Fatal("matching must not create a purchase task")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user