Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b311885ea8 | ||
|
|
a4a793f449 | ||
|
|
3ee8dfc0aa | ||
|
|
555a954605 | ||
|
|
1e924fa411 | ||
|
|
92aeab8d04 | ||
|
|
c9b7a2dc0d | ||
|
|
dbf9a5f460 | ||
|
|
8f07a19512 | ||
|
|
2c58d5817c |
@@ -31,7 +31,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = "-370-diag1"
|
||||
versionNameSuffix = "-373-fix1"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
+33
-50
@@ -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) {
|
||||
@@ -1267,10 +1252,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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+108
-5
@@ -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))
|
||||
},
|
||||
@@ -822,6 +890,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 +921,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 +1000,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 +1052,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 +1128,7 @@ class AgentForegroundService : Service() {
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
): TaskExecutionSummary {
|
||||
probeHandoff.beginExecution()
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
return try {
|
||||
@@ -1404,6 +1502,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 +1657,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 +1672,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 +1687,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"
|
||||
|
||||
@@ -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") }))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+302
-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,7 +716,7 @@ 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)
|
||||
@@ -540,7 +726,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
@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 +741,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 +757,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 +775,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 +792,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 +810,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 +825,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 +837,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 +858,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 +870,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 +883,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 +895,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 +905,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 +916,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 +925,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 +937,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 +950,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 +961,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 +1011,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 +1027,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 +1043,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 +1062,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 +1077,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 +1089,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 +1104,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 +1116,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 +1126,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 +1145,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 +1160,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 +1174,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 +1187,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 +1208,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 +1243,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 +1254,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 +1264,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 +1275,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 +1286,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 +1295,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 +1315,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 +1326,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 +1347,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 +1363,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 +1376,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 +1386,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 +1396,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 +1513,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 +1525,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -213,7 +213,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"}]}"""),
|
||||
|
||||
@@ -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: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
|
||||
synchronized_at: 2026-10-09T07:45:10Z
|
||||
wiki_revision: 08da88a4f106004cb5f02448260d2fc22a83e9cc
|
||||
synchronized_at: 2026-10-10T00:59:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -673,3 +673,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
|
||||
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
|
||||
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
|
||||
|
||||
## Admin 列表按当前筛选导出全部页(#372,待验收)
|
||||
|
||||
实现绑定 `a4a793f`(分支 `feat/372-list-export`,未合并、未发布)。
|
||||
|
||||
- 采购管理与 SYB 商品页各增「导出」按钮:`web/src/utils/list-export.js` 先按当前筛选读第 1 页取总数,超过 5000 条不导出,否则逐页读取(采购每页 100、SYB 每页 500)再用 `web/src/vendor/Export2Excel.js`(经 `utils/save-excel.js` 按需加载)生成 xlsx;任一页或批次失败不生成文件;筛选条件以点击时复制的参数为准。无服务端导出接口、无异步导出任务。
|
||||
- `GET /api/admin/v1/purchase-tasks` 的 `AdminListRequest`(`server/app/goauto/purchase/admin_query.go`)新增可选 `orderSubmittedFrom` / `orderSubmittedTo`(`YYYY-MM-DD`,含首尾两天,按 `order_submitted_at`,UTC+8 与 SYB 创建时间筛选一致);格式错误或起止倒置返回 `INVALID_REQUEST`;无下单时间的任务在设置该筛选时不出现。采购页筛选栏用日期范围选择器。
|
||||
- `GET /api/admin/v1/return-matches` 的 `returnmatch.ListItem` 新增只读 `yeekeQuantity`(`server/app/goauto/returnmatch/service.go`,来自既有 `yeeke_return_item` join,不参与匹配);该接口原本就支持一次传多个 `sybProductId`,SYB 导出按每批 200 个商品 ID 调用,不逐行请求,且只采用 `matched`/`confirmed` 的匹配。
|
||||
- 无数据库迁移、无新接口、无新权限;导出按钮不单独鉴权,能打开列表的用户(含采购员)都可导出。
|
||||
|
||||
@@ -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: f8a5ca861965f5da9bdec2235205f23fb9a00840
|
||||
synchronized_at: 2026-10-09T07:45:19Z
|
||||
wiki_revision: 1c57a8670034fe08420bcac48b58a9c4b2755f23
|
||||
synchronized_at: 2026-10-10T00:59:12Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -891,7 +891,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 采购页面一次性交接与导航稳定等待(#373 v4)
|
||||
|
||||
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
|
||||
实现绑定分支 `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耗时保证。
|
||||
@@ -905,7 +905,9 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 下单前最终规格复核(#374 v3)
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(2026-10-09,工单分支 `fix/374-final-spec-confirmation`,基于 main `e26743c`);尚未合并、安装或发布,不含 #373 导航修复,真机采购未验收。
|
||||
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
|
||||
|
||||
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
|
||||
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
|
||||
@@ -916,3 +918,42 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 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测试通过(既有图搜返回忙循环用例不在本次回归范围)。
|
||||
|
||||
## Android 采购结果明确拒收恢复(#377)
|
||||
|
||||
实现位于工单分支 fix/377-rejected-purchase-outbox(实现提交 8b75a95,运行占位与互斥补齐 309dce3);仅 Android,未合并 main、未安装或发布,真机恢复尚待独立授权验证。
|
||||
|
||||
- 每轮先发心跳,再在无任务执行锁时恢复中断任务及补传。心跳任务不一致不能挡住拒收收敛;收尾后只额外重发一次心跳。认证错误停止补传,只有最终心跳成功、没有未完成本地任务/待上传结果且收尾正常才领取新任务。不放宽服务端任务身份校验,不修改心跳间隔或离线阈值。
|
||||
- 只有 HTTP 409 且错误码精确属于 PURCHASE_STATE_CONFLICT、PURCHASE_LEASE_EXPIRED、PURCHASE_RESULT_CONFLICT 才视为明确拒收。未知 409、网络异常、5xx 及认证错误不按拒收处理;缺少 retryable 不构成拒收证据。
|
||||
- 本地拒收使用 rejected,不伪装 sent。按 outbox 与 task/attempt 归属事务保存,保留原 payload;不再自动补传该条,继续处理后续结果。拒收不触发上传成功或规格探测交接,交接凭证失效。拒收记录不再充当待上传占位;不改变服务端采购结果和原重购停止边界。
|
||||
- order_created、order_result_unknown 或带订单号的拒收结果在采购页持续提示人工核对。“已核对”只持久隐藏提醒,不删除结果、不调用服务端、不允许再次下单;非订单拒收仍可在设置及任务详情查看。
|
||||
- goauto_purchase.db v2→v3 只追加拒收时间、白名单错误码及人工核对时间三个可空列,旧结果与记录保持不变;v1 升级仍经过原 v2 追加列。旧版 SQLiteOpenHelper 不保证能打开 v3,不能靠卸载清数据回退,优先使用保留 v3 schema 的修复版本。
|
||||
- 设置页区分同步中、真实心跳失败、已连接但有拒收;新说明采用固定文案,不展示任意服务端错误消息。本单只修复明确拒收后的持续阻塞,不代表最初心跳中断的原因已查明,历史诊断仍属于 #378。
|
||||
|
||||
- 拒收后只在执行锁空闲、当前运行标记属于同一采购任务且本地当前 attempt 已拒收时清理恢复占位,不清除其他采集/采购或新 attempt;手动当前页采集与同步共用既有 working/任务锁门禁。服务停止写 STOPPED,原服务实例的迟到状态更新不覆盖它。
|
||||
|
||||
## Admin 列表导出 Excel(#372,待验收)
|
||||
|
||||
实现绑定 `a4a793f`,分支 `feat/372-list-export`,未合并、未发布。
|
||||
|
||||
- 范围:采购管理和 SYB 商品页的「导出」导出**当前筛选条件下的全部页**,不是只导出当前页;筛选以点击导出时的值为准。总数超过 **5000** 条时提示缩小筛选范围并不导出;任一页读取失败不生成文件;无数据提示且不生成文件。分页读取期间数据变化可能有少量重复或遗漏,以导出时刻近似结果为准。
|
||||
- 格式:时间 `YYYY-MM-DD HH:mm:ss`(浏览器本地时区);金额单位元,保留两位小数;空值留空;文件名 `采购导出_YYYYMMDD-HHmmss.xlsx`、`SYB商品导出_YYYYMMDD-HHmmss.xlsx`。不导出收货人、地址、账号等个人信息。
|
||||
- 采购导出列(按顺序):SYB订单号、虾皮商品ID、颜色、尺码(均为 SYB 目标规格快照)、数量、状态(页面同款中文)、PDD单号、下单时间、价格(元)。价格取 `pddOrderAmountCent / 100`,即 Agent 在 PDD 待付款页读到的应付金额;旧版 Agent 或人工补录前为空则留空。要导出成功采购,先把状态筛为「订单已创建」并按需选择下单日期。
|
||||
- 采购管理新增「下单日期」筛选:含首尾两天,按 `order_submitted_at`,日期边界 UTC+8,与 SYB 商品页创建时间筛选一致;没有下单时间的任务在设置该筛选时不出现。
|
||||
- SYB 商品导出列(按顺序):SYB订单、虾皮商品ID、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
|
||||
- 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。
|
||||
|
||||
@@ -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: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
|
||||
synchronized_at: 2026-10-09T07:45:33Z
|
||||
wiki_revision: 3f69f2e39675752dfd43dc38de41cb7defb74a42
|
||||
synchronized_at: 2026-10-09T07:58:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -365,3 +365,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 主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`。
|
||||
- 本次仅合并、验证和推送,未覆盖安装、未执行真实采购/付款、未发布或重启服务;不需要业务库迁移。手机仍使用此前安装版本。后续装机须另行授权,保留原签名及用户数据。
|
||||
|
||||
@@ -25,6 +25,46 @@ type AdminListRequest struct {
|
||||
ShopeeOrderNo string
|
||||
ShopeeOrderNos []string
|
||||
PDDOrderNo string
|
||||
// OrderSubmittedFrom/To are inclusive YYYY-MM-DD days (UTC+8, same as the
|
||||
// SYB products createdFrom/createdTo filter) over order_submitted_at.
|
||||
OrderSubmittedFrom string
|
||||
OrderSubmittedTo string
|
||||
}
|
||||
|
||||
// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
|
||||
// day boundary (sybimport.sybStockLocation) so both pages filter the same way.
|
||||
var adminOrderDayLocation = time.FixedZone("SYB UTC+8", 8*60*60)
|
||||
|
||||
// adminOrderSubmittedRange returns [start, endExclusive); a nil bound means
|
||||
// that side is open. Both days are inclusive for the caller.
|
||||
func adminOrderSubmittedRange(from, to string) (*time.Time, *time.Time, error) {
|
||||
from, to = strings.TrimSpace(from), strings.TrimSpace(to)
|
||||
parse := func(value, label string) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, adminOrderDayLocation)
|
||||
if err != nil {
|
||||
return nil, fail(CodeInvalidRequest, label+" 必须是 YYYY-MM-DD")
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
start, err := parse(from, "orderSubmittedFrom")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
endDay, err := parse(to, "orderSubmittedTo")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if start != nil && endDay != nil && start.After(*endDay) {
|
||||
return nil, nil, fail(CodeInvalidRequest, "orderSubmittedFrom 不能晚于 orderSubmittedTo")
|
||||
}
|
||||
if endDay != nil {
|
||||
end := endDay.AddDate(0, 0, 1)
|
||||
endDay = &end
|
||||
}
|
||||
return start, endDay, nil
|
||||
}
|
||||
|
||||
type AdminTaskItem struct {
|
||||
@@ -175,7 +215,17 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.TaskType != "" && req.TaskType != models.PurchaseTaskTypeSYBOrder && req.TaskType != models.PurchaseTaskTypeStock {
|
||||
return AdminListResponse{}, fail(CodeInvalidRequest, "taskType 无效")
|
||||
}
|
||||
submittedFrom, submittedTo, err := adminOrderSubmittedRange(req.OrderSubmittedFrom, req.OrderSubmittedTo)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
|
||||
if submittedFrom != nil {
|
||||
query = query.Where("order_submitted_at >= ?", *submittedFrom)
|
||||
}
|
||||
if submittedTo != nil {
|
||||
query = query.Where("order_submitted_at < ?", *submittedTo)
|
||||
}
|
||||
if req.TaskID > 0 {
|
||||
query = query.Where("id = ?", req.TaskID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
)
|
||||
|
||||
// #372: order_submitted_at day filter, inclusive on both ends, UTC+8 days.
|
||||
func TestAdminOrderSubmittedRange(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
s := testService(db)
|
||||
base := createAdminQueryTask(t, s, f)
|
||||
loc := time.FixedZone("UTC+8", 8*3600)
|
||||
at := func(day string, h, m int) *time.Time {
|
||||
d, _ := time.ParseInLocation("2006-01-02", day, loc)
|
||||
v := d.Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)
|
||||
return &v
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
at *time.Time
|
||||
}{
|
||||
{"before", models.PurchaseTaskStatusOrderCreated, at("2026-10-01", 23, 59)},
|
||||
{"first-day-start", models.PurchaseTaskStatusOrderCreated, at("2026-10-02", 0, 0)},
|
||||
{"middle", models.PurchaseTaskStatusOrderCreated, at("2026-10-03", 12, 0)},
|
||||
{"last-day-end", models.PurchaseTaskStatusOrderCreated, at("2026-10-04", 23, 59)},
|
||||
{"after", models.PurchaseTaskStatusOrderCreated, at("2026-10-05", 0, 0)},
|
||||
{"middle-failed", models.PurchaseTaskStatusFailed, at("2026-10-03", 13, 0)},
|
||||
{"no-time", models.PurchaseTaskStatusOrderCreated, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
row := base
|
||||
row.ID = 0
|
||||
row.CreateRequestID = uuid.NewString()
|
||||
row.ShopeeOrderNoSnapshot = tc.name
|
||||
row.Status = tc.status
|
||||
row.OrderSubmittedAt = tc.at
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
names := func(req AdminListRequest) map[string]bool {
|
||||
t.Helper()
|
||||
out, err := s.AdminList(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, item := range out.Items {
|
||||
got[item.ShopeeOrderNoSnapshot] = true
|
||||
}
|
||||
if int(out.Total) != len(got) {
|
||||
t.Fatalf("total %d != items %d", out.Total, len(got))
|
||||
}
|
||||
return got
|
||||
}
|
||||
both := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04"})
|
||||
if len(both) != 4 || !both["first-day-start"] || !both["middle"] || !both["last-day-end"] || !both["middle-failed"] {
|
||||
t.Fatalf("both days must be inclusive: %v", both)
|
||||
}
|
||||
combined := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04", Status: models.PurchaseTaskStatusOrderCreated})
|
||||
if len(combined) != 3 || combined["middle-failed"] {
|
||||
t.Fatalf("must combine with status: %v", combined)
|
||||
}
|
||||
if only := names(AdminListRequest{OrderSubmittedFrom: "2026-10-04"}); len(only) != 2 || !only["last-day-end"] || !only["after"] {
|
||||
t.Fatalf("from only: %v", only)
|
||||
}
|
||||
if only := names(AdminListRequest{OrderSubmittedTo: "2026-10-02"}); len(only) != 2 || !only["before"] || !only["first-day-start"] {
|
||||
t.Fatalf("to only: %v", only)
|
||||
}
|
||||
if one := names(AdminListRequest{OrderSubmittedFrom: "2026-10-03", OrderSubmittedTo: "2026-10-03"}); len(one) != 2 {
|
||||
t.Fatalf("single day: %v", one)
|
||||
}
|
||||
if none := names(AdminListRequest{}); !none["no-time"] {
|
||||
t.Fatalf("no filter must keep rows without order time: %v", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrderSubmittedRangeInvalid(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
s := testService(db)
|
||||
createAdminQueryTask(t, s, f)
|
||||
for _, req := range []AdminListRequest{
|
||||
{OrderSubmittedFrom: "2026/10/02"},
|
||||
{OrderSubmittedTo: "10-02"},
|
||||
{OrderSubmittedFrom: "2026-13-01"},
|
||||
{OrderSubmittedFrom: "2026-10-05", OrderSubmittedTo: "2026-10-04"},
|
||||
} {
|
||||
if _, err := s.AdminList(context.Background(), req); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("%+v: expected invalid request, got %v", req, err)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"orderSubmittedFrom=abc", "orderSubmittedTo=2026-02-30"} {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, nil)
|
||||
(Handler{DB: db}).AdminList(c)
|
||||
if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
|
||||
t.Fatalf("handler accepted %s: %s", query, w.Body.String())
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?orderSubmittedFrom=2026-10-01&orderSubmittedTo=2026-10-02", nil)
|
||||
(Handler{DB: db}).AdminList(c)
|
||||
var result struct {
|
||||
Data AdminListResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK {
|
||||
t.Fatalf("valid range: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,12 @@ func (h Handler) AdminList(c *gin.Context) {
|
||||
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
|
||||
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
|
||||
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
|
||||
TaskType: strings.TrimSpace(c.Query("taskType")),
|
||||
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
|
||||
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
|
||||
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
|
||||
TaskType: strings.TrimSpace(c.Query("taskType")),
|
||||
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
|
||||
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
|
||||
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
|
||||
OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
|
||||
OrderSubmittedTo: c.Query("orderSubmittedTo"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -495,9 +495,12 @@ type ListFilter struct {
|
||||
// sn, spec) so it never has to issue a second request per row.
|
||||
type ListItem struct {
|
||||
models.ReturnMatch
|
||||
OrderSN string `json:"orderSn,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
VariationName string `json:"variationName,omitempty"`
|
||||
OrderSN string `json:"orderSn,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
VariationName string `json:"variationName,omitempty"`
|
||||
// YeekeQuantity is the yeeke return item's own quantity (read-only, #372
|
||||
// export); it comes from the same join and is never used for matching.
|
||||
YeekeQuantity int64 `json:"yeekeQuantity"`
|
||||
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
|
||||
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
|
||||
// current availability ("ok"/"missing") so the SYB products match column
|
||||
@@ -538,6 +541,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
OrderSN string
|
||||
Image string
|
||||
VariationName string
|
||||
Quantity int64
|
||||
DestroyDeadLine *time.Time
|
||||
SyncStatus string
|
||||
MissingSince *time.Time
|
||||
@@ -545,7 +549,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
|
||||
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
|
||||
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, i.quantity AS quantity, p.destroy_dead_line AS destroy_dead_line, "+
|
||||
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
|
||||
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
|
||||
Where("i.id IN ?", returnIDs).
|
||||
@@ -556,6 +560,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
OrderSN string
|
||||
Image string
|
||||
VariationName string
|
||||
Quantity int64
|
||||
DestroyDeadLine *time.Time
|
||||
SyncStatus string
|
||||
MissingSince *time.Time
|
||||
@@ -569,13 +574,14 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
|
||||
if j.PackageSyncStat != "ok" {
|
||||
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
|
||||
}
|
||||
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
|
||||
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.Quantity, j.DestroyDeadLine, syncStatus, missingSince}
|
||||
}
|
||||
for i := range items {
|
||||
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
|
||||
items[i].OrderSN = extra.OrderSN
|
||||
items[i].Image = extra.Image
|
||||
items[i].VariationName = extra.VariationName
|
||||
items[i].YeekeQuantity = extra.Quantity
|
||||
items[i].DestroyDeadline = extra.DestroyDeadLine
|
||||
items[i].SyncStatus = extra.SyncStatus
|
||||
items[i].MissingSince = extra.MissingSince
|
||||
|
||||
@@ -2,6 +2,7 @@ package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -484,3 +485,27 @@ func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
|
||||
t.Fatalf("expected enriched list item: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// #372: List exposes the yeeke return item's quantity (read-only export field).
|
||||
func TestList_ExposesYeekeQuantity(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
syb := seedSYB(t, db, "SYB-Q", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
item := seedReturn(t, db, "白色,L", &deadline)
|
||||
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", item.ID).Update("quantity", 3).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}); err != nil || resp.MatchedCount != 1 {
|
||||
t.Fatalf("setup failed: %v %+v", err, resp)
|
||||
}
|
||||
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil || len(items) != 1 || items[0].YeekeQuantity != 3 {
|
||||
t.Fatalf("expected yeekeQuantity 3: %+v %v", items, err)
|
||||
}
|
||||
raw, _ := json.Marshal(items[0])
|
||||
if !strings.Contains(string(raw), `"yeekeQuantity":3`) {
|
||||
t.Fatalf("json missing yeekeQuantity: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// #372: export every page of an admin list (current filters) to xlsx.
|
||||
// Pure helpers with injected dependencies so they can be unit tested without a
|
||||
// browser; the pages inject the API calls and the Export2Excel saver.
|
||||
|
||||
export const EXPORT_MAX_ROWS = 5000
|
||||
|
||||
const pad = (value, width = 2) => String(value).padStart(width, '0')
|
||||
|
||||
// YYYY-MM-DD HH:mm:ss in the browser's local time zone; empty for blank/invalid.
|
||||
export function formatExportTime(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
// YYYYMMDD-HHmmss for file names.
|
||||
export function formatExportStamp(date = new Date()) {
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
// Cents -> yuan text with two decimals; null/undefined/'' stay empty.
|
||||
export function centToYuan(cent) {
|
||||
if (cent === null || cent === undefined || cent === '') return ''
|
||||
const value = Number(cent)
|
||||
if (!Number.isFinite(value)) return ''
|
||||
const sign = value < 0 ? '-' : ''
|
||||
const abs = Math.abs(Math.round(value))
|
||||
return `${sign}${Math.floor(abs / 100)}.${pad(abs % 100)}`
|
||||
}
|
||||
|
||||
// yeeke only has one variation_name ("color,size"); split at the first English
|
||||
// or Chinese comma. Without a comma the whole text is the color.
|
||||
export function splitYeekeVariation(name) {
|
||||
const text = String(name ?? '').trim()
|
||||
if (!text) return { color: '', size: '' }
|
||||
const index = text.search(/[,,]/)
|
||||
if (index < 0) return { color: text, size: '' }
|
||||
return { color: text.slice(0, index).trim(), size: text.slice(index + 1).trim() }
|
||||
}
|
||||
|
||||
const blank = value => (value === null || value === undefined ? '' : value)
|
||||
|
||||
export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)']
|
||||
|
||||
// statusLabel: the page's own status -> Chinese label function.
|
||||
export function purchaseExportRow(task, statusLabel = value => value) {
|
||||
return [
|
||||
blank(task.shopeeOrderNoSnapshot), blank(task.shopeeItemIdSnapshot), blank(task.targetColorSnapshot), blank(task.targetSizeSnapshot),
|
||||
blank(task.quantity), task.status ? statusLabel(task.status) : '', blank(task.pddOrderNo),
|
||||
formatExportTime(task.orderSubmittedAt), centToYuan(task.pddOrderAmountCent)
|
||||
]
|
||||
}
|
||||
|
||||
export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '虾皮商品ID', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间']
|
||||
|
||||
export const isActiveReturnMatch = match => Boolean(match) && (match.status === 'matched' || match.status === 'confirmed')
|
||||
|
||||
// match: the active (matched/confirmed) return match of this product, or null.
|
||||
export function sybProductExportRow(product, match) {
|
||||
const active = isActiveReturnMatch(match) ? match : null
|
||||
const yeeke = active ? splitYeekeVariation(active.variationName) : { color: '', size: '' }
|
||||
return [
|
||||
blank(product.orderCode), blank(product.shopeeItemId), blank(product.targetColor), blank(product.targetSize), blank(product.quantity),
|
||||
yeeke.color, yeeke.size, active ? blank(active.yeekeQuantity) : '', active ? formatExportTime(active.matchedAt) : ''
|
||||
]
|
||||
}
|
||||
|
||||
export function exportFailureMessage(result, maxRows = EXPORT_MAX_ROWS) {
|
||||
switch (result.reason) {
|
||||
case 'empty': return '当前筛选没有数据,未生成文件'
|
||||
case 'too_many': return `共 ${result.total} 条,超过 ${maxRows} 条上限,请缩小筛选范围`
|
||||
case 'page_failed': return `导出失败:第 ${result.page} 页读取失败,未生成文件`
|
||||
case 'enrich_failed': return '导出失败:匹配信息读取失败,未生成文件'
|
||||
default: return '导出失败,未生成文件'
|
||||
}
|
||||
}
|
||||
|
||||
function chunk(items, size) {
|
||||
const result = []
|
||||
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
|
||||
return result
|
||||
}
|
||||
|
||||
// Reads page 1 for the total, refuses above maxRows, then reads the remaining
|
||||
// pages. Any failure returns { ok: false } and nothing is saved.
|
||||
// fetchPage(page, pageSize) -> { items, total }
|
||||
// enrich(items) -> decorated items of the same length (optional, per batch)
|
||||
// mapRow(item) -> array of cells
|
||||
// save({ header, data, filename }) -> writes the file (only after success)
|
||||
export async function runListExport({
|
||||
fetchPage, pageSize, maxRows = EXPORT_MAX_ROWS, enrich = null, enrichBatchSize = 200,
|
||||
header, mapRow, filename, save, onProgress = () => {}
|
||||
}) {
|
||||
let first
|
||||
try {
|
||||
first = await fetchPage(1, pageSize)
|
||||
} catch (error) {
|
||||
return { ok: false, reason: 'page_failed', page: 1, error }
|
||||
}
|
||||
const total = Number(first?.total) || 0
|
||||
if (total === 0) return { ok: false, reason: 'empty', total: 0 }
|
||||
if (total > maxRows) return { ok: false, reason: 'too_many', total }
|
||||
const pages = Math.ceil(total / pageSize)
|
||||
const enrichSteps = enrich ? Math.ceil(total / enrichBatchSize) : 0
|
||||
const steps = pages + enrichSteps
|
||||
let done = 1
|
||||
onProgress(done, steps)
|
||||
let items = [...(first.items || [])]
|
||||
for (let page = 2; page <= pages; page++) {
|
||||
let response
|
||||
try {
|
||||
response = await fetchPage(page, pageSize)
|
||||
} catch (error) {
|
||||
return { ok: false, reason: 'page_failed', page, error }
|
||||
}
|
||||
items = items.concat(response?.items || [])
|
||||
onProgress(++done, steps)
|
||||
}
|
||||
if (enrich) {
|
||||
const decorated = []
|
||||
for (const part of chunk(items, enrichBatchSize)) {
|
||||
try {
|
||||
decorated.push(...await enrich(part))
|
||||
} catch (error) {
|
||||
return { ok: false, reason: 'enrich_failed', error }
|
||||
}
|
||||
onProgress(++done, steps)
|
||||
}
|
||||
items = decorated
|
||||
}
|
||||
if (items.length === 0) return { ok: false, reason: 'empty', total: 0 }
|
||||
const data = items.map(mapRow)
|
||||
await save({ header, data, filename: `${filename}_${formatExportStamp()}` })
|
||||
return { ok: true, total, rows: data.length }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Loads the xlsx writer on demand so the list pages do not pull it into their chunk.
|
||||
export async function saveExcel(file) {
|
||||
const { export_json_to_excel: exportJsonToExcel } = await import('@/vendor/Export2Excel')
|
||||
exportJsonToExcel(file)
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="exporting || Boolean(orderFilter.error)" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</el-button></el-form-item>
|
||||
</el-form>
|
||||
<div class="batch-actions"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retryCandidates.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retryCandidates.length }})</el-button><el-button type="primary" plain :disabled="orderWritebackCandidates.length === 0" :loading="orderWriteback.saving" @click="submitOrderWriteback(orderWritebackCandidates)">批量回填 SYB({{ orderWritebackCandidates.length }})</el-button></div>
|
||||
|
||||
@@ -154,6 +155,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { saveExcel } from '@/utils/save-excel'
|
||||
import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export'
|
||||
import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots'
|
||||
import FailureSnapshotCell from './FailureSnapshotCell.vue'
|
||||
|
||||
@@ -169,9 +172,10 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }],
|
||||
exporting: false, exportProgress: { done: 0, steps: 0 },
|
||||
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
|
||||
orderWriteback: { open: false, saving: false, items: [] },
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] },
|
||||
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
|
||||
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
|
||||
retrySelection: [],
|
||||
@@ -227,14 +231,34 @@ export default {
|
||||
try { const r = await listPurchaseTasks(this.cleanQuery()); this.tasks = r.data.items || []; this.total = r.data.total || 0; this.retrySelection = []; this.$nextTick(() => this.$refs.taskTable?.clearSelection()) } catch (error) { this.tasks = []; this.total = 0; this.loadError = error?.message || '采购任务加载失败,请稍后重试' } finally { this.loading = false }
|
||||
},
|
||||
cleanQuery() {
|
||||
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && value !== ''))
|
||||
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && key !== 'orderSubmittedRange' && value !== ''))
|
||||
const [orderSubmittedFrom, orderSubmittedTo] = this.query.orderSubmittedRange || []
|
||||
if (orderSubmittedFrom) params.orderSubmittedFrom = orderSubmittedFrom
|
||||
if (orderSubmittedTo) params.orderSubmittedTo = orderSubmittedTo
|
||||
const values = this.orderFilter.values
|
||||
if (values.length === 1) params.shopeeOrderNo = values[0]
|
||||
else if (values.length > 1) params.shopeeOrderNos = values
|
||||
return params
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }; this.load() },
|
||||
// #372: export every page of the filters as they are at click time (params are copied once).
|
||||
async exportExcel() {
|
||||
if (this.exporting || this.orderFilter.error) return
|
||||
const filters = this.cleanQuery(); delete filters.page; delete filters.pageSize
|
||||
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
|
||||
try {
|
||||
const result = await runListExport({
|
||||
pageSize: 100, maxRows: EXPORT_MAX_ROWS, header: PURCHASE_EXPORT_HEADER, filename: '采购导出',
|
||||
fetchPage: async(pageNo, size) => { const r = await listPurchaseTasks({ ...filters, page: pageNo, pageSize: size }); return { items: r.data.items || [], total: r.data.total || 0 } },
|
||||
mapRow: task => purchaseExportRow(task, this.statusLabel),
|
||||
save: saveExcel,
|
||||
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
|
||||
})
|
||||
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
|
||||
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
|
||||
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
|
||||
},
|
||||
clearSnapshots() { this.detailSession++; this.snapshots = { items: {}, loading: false, error: false } },
|
||||
async loadSnapshots() {
|
||||
if (!this.isSnapshotAdmin || !this.detail.open || !this.detail.task || this.snapshots.loading) return
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px" @change="onProcessStageChange"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="采购类型"><el-select v-model="query.purchaseType" style="width: 132px"><el-option label="需 PDD 采购" value="pdd" /><el-option label="无需 PDD 采购" value="excluded" /><el-option label="全部" value="all" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="exporting" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
</div>
|
||||
<div class="search-row search-row-secondary">
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
|
||||
@@ -395,6 +395,8 @@ import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks
|
||||
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
|
||||
import { saveExcel } from '@/utils/save-excel'
|
||||
import { EXPORT_MAX_ROWS, SYB_PRODUCT_EXPORT_HEADER, exportFailureMessage, isActiveReturnMatch, runListExport, sybProductExportRow } from '@/utils/list-export'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
||||
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
|
||||
@@ -430,6 +432,8 @@ export default {
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
|
||||
exporting: false,
|
||||
exportProgress: { done: 0, steps: 0 },
|
||||
returnMatchByProductId: {},
|
||||
returnMatchLoading: false,
|
||||
returnMatchBatchLoading: false,
|
||||
@@ -529,6 +533,34 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
// #372: export every page of the filters as they are at click time (params are copied once);
|
||||
// return matches are read per batch of product ids, never per row.
|
||||
async exportExcel() {
|
||||
if (this.exporting) return
|
||||
const orderCodes = this.normalizedOrderCodes()
|
||||
if (orderCodes === null) return
|
||||
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
|
||||
const filters = { shopIds: [...this.query.shopIds], orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }
|
||||
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
|
||||
try {
|
||||
const result = await runListExport({
|
||||
pageSize: 500, maxRows: EXPORT_MAX_ROWS, header: SYB_PRODUCT_EXPORT_HEADER, filename: 'SYB商品导出',
|
||||
fetchPage: async(page, size) => { const r = await listSybProducts({ ...filters, page, pageSize: size }, { suppressErrorMessage: true }); return { items: r.data.items || [], total: r.data.total || 0 } },
|
||||
enrichBatchSize: 200,
|
||||
enrich: async items => {
|
||||
const r = await listReturnMatches({ sybProductId: items.map(item => item.id) }, { suppressErrorMessage: true })
|
||||
const byProduct = {}
|
||||
for (const match of (r.data?.items || [])) if (isActiveReturnMatch(match) && !byProduct[match.sybProductId]) byProduct[match.sybProductId] = match
|
||||
return items.map(item => ({ item, match: byProduct[item.id] || null }))
|
||||
},
|
||||
mapRow: ({ item, match }) => sybProductExportRow(item, match),
|
||||
save: saveExcel,
|
||||
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
|
||||
})
|
||||
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
|
||||
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
|
||||
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
|
||||
},
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
|
||||
onPagination() {
|
||||
// Both controls may emit after a size change clamps the old page. Coalesce
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/list-export.js'), 'utf8')
|
||||
const lib = new Function(source.replace(/^export /gm, '') + `
|
||||
return { EXPORT_MAX_ROWS, formatExportTime, formatExportStamp, centToYuan, splitYeekeVariation, purchaseExportRow, PURCHASE_EXPORT_HEADER,
|
||||
sybProductExportRow, SYB_PRODUCT_EXPORT_HEADER, exportFailureMessage, runListExport }`)()
|
||||
|
||||
test('time and stamp use local time, empty and invalid stay empty', () => {
|
||||
const date = new Date(2026, 9, 9, 8, 5, 3)
|
||||
assert.equal(lib.formatExportTime(date.toISOString()), '2026-10-09 08:05:03')
|
||||
assert.equal(lib.formatExportTime(null), '')
|
||||
assert.equal(lib.formatExportTime(''), '')
|
||||
assert.equal(lib.formatExportTime('not a date'), '')
|
||||
assert.equal(lib.formatExportStamp(date), '20261009-080503')
|
||||
})
|
||||
|
||||
test('cent to yuan keeps two decimals and leaves empty values empty', () => {
|
||||
assert.equal(lib.centToYuan(12345), '123.45')
|
||||
assert.equal(lib.centToYuan(5), '0.05')
|
||||
assert.equal(lib.centToYuan(1000), '10.00')
|
||||
assert.equal(lib.centToYuan(0), '0.00')
|
||||
for (const value of [null, undefined, '']) assert.equal(lib.centToYuan(value), '')
|
||||
})
|
||||
|
||||
test('yeeke variation splits at the first English or Chinese comma, none puts all in color', () => {
|
||||
assert.deepEqual(lib.splitYeekeVariation('白色,L【建議65-75公斤】'), { color: '白色', size: 'L【建議65-75公斤】' })
|
||||
assert.deepEqual(lib.splitYeekeVariation('黑色+白色,簡約,L'), { color: '黑色+白色', size: '簡約,L' })
|
||||
assert.deepEqual(lib.splitYeekeVariation('均碼'), { color: '均碼', size: '' })
|
||||
assert.deepEqual(lib.splitYeekeVariation(''), { color: '', size: '' })
|
||||
assert.deepEqual(lib.splitYeekeVariation(undefined), { color: '', size: '' })
|
||||
})
|
||||
|
||||
test('purchase row maps columns in order with status label, amount and blanks', () => {
|
||||
assert.equal(lib.PURCHASE_EXPORT_HEADER.length, 9)
|
||||
const row = lib.purchaseExportRow({
|
||||
shopeeOrderNoSnapshot: '001', shopeeItemIdSnapshot: '5020', targetColorSnapshot: '白色', targetSizeSnapshot: 'L', quantity: 2,
|
||||
status: 'order_created', pddOrderNo: 'PDD-1', orderSubmittedAt: new Date(2026, 9, 9, 1, 2, 3).toISOString(), pddOrderAmountCent: 3990,
|
||||
receiverName: 'must not leak', address: 'must not leak'
|
||||
}, value => ({ order_created: '订单已创建' })[value])
|
||||
assert.deepEqual(row, ['001', '5020', '白色', 'L', 2, '订单已创建', 'PDD-1', '2026-10-09 01:02:03', '39.90'])
|
||||
assert.deepEqual(lib.purchaseExportRow({ quantity: 1 }), ['', '', '', '', 1, '', '', '', ''])
|
||||
assert.equal(lib.purchaseExportRow({ shopeeOrderNoSnapshot: 'A', status: 'failed', pddOrderAmountCent: null })[8], '')
|
||||
})
|
||||
|
||||
test('syb product row uses only an active match and ignores cancelled ones', () => {
|
||||
const product = { orderCode: 'O1', shopeeItemId: '99', targetColor: '白色', targetSize: 'L', quantity: 1 }
|
||||
const matchedAt = new Date(2026, 9, 9, 9, 0, 0).toISOString()
|
||||
assert.equal(lib.SYB_PRODUCT_EXPORT_HEADER.length, 9)
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }),
|
||||
['O1', '99', '白色', 'L', 1, '白色', 'L', 3, '2026-10-09 09:00:00'])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'matched', variationName: '均碼', yeekeQuantity: 1, matchedAt }).slice(5, 8), ['均碼', '', 1])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', '99', '白色', 'L', 1, '', '', '', ''])
|
||||
assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', '99', '白色', 'L', 1, '', '', '', ''])
|
||||
})
|
||||
|
||||
function harness(total, options = {}) {
|
||||
const calls = []; const saved = []; const progress = []
|
||||
const pageSize = options.pageSize || 100
|
||||
const run = () => lib.runListExport({
|
||||
pageSize, header: ['id'], filename: '测试', mapRow: item => [item.id],
|
||||
fetchPage: async (page, size) => {
|
||||
calls.push(page)
|
||||
if (options.failPage === page) throw new Error('boom')
|
||||
const start = (page - 1) * size
|
||||
return { total, items: Array.from({ length: Math.max(0, Math.min(size, total - start)) }, (_, i) => ({ id: start + i + 1 })) }
|
||||
},
|
||||
save: async file => { saved.push(file) }, onProgress: (done, steps) => progress.push([done, steps]), ...options.extra
|
||||
})
|
||||
return { run, calls, saved, progress }
|
||||
}
|
||||
|
||||
test('reads every page, reports progress, saves once with a timestamped name', async () => {
|
||||
const h = harness(250)
|
||||
const result = await h.run()
|
||||
assert.deepEqual(result, { ok: true, total: 250, rows: 250 })
|
||||
assert.deepEqual(h.calls, [1, 2, 3])
|
||||
assert.deepEqual(h.progress, [[1, 3], [2, 3], [3, 3]])
|
||||
assert.equal(h.saved.length, 1)
|
||||
assert.equal(h.saved[0].data.length, 250)
|
||||
assert.deepEqual(h.saved[0].data[249], [250])
|
||||
assert.match(h.saved[0].filename, /^测试_\d{8}-\d{6}$/)
|
||||
})
|
||||
|
||||
test('exactly 5000 rows are allowed, 5001 is refused after reading only page 1', async () => {
|
||||
const ok = harness(5000, { pageSize: 500 })
|
||||
assert.equal((await ok.run()).ok, true)
|
||||
assert.equal(ok.calls.length, 10)
|
||||
const over = harness(5001, { pageSize: 500 })
|
||||
const result = await over.run()
|
||||
assert.deepEqual(result, { ok: false, reason: 'too_many', total: 5001 })
|
||||
assert.deepEqual(over.calls, [1])
|
||||
assert.equal(over.saved.length, 0)
|
||||
assert.equal(lib.exportFailureMessage(result), '共 5001 条,超过 5000 条上限,请缩小筛选范围')
|
||||
})
|
||||
|
||||
test('empty result does not save', async () => {
|
||||
const h = harness(0)
|
||||
const result = await h.run()
|
||||
assert.deepEqual(result, { ok: false, reason: 'empty', total: 0 })
|
||||
assert.equal(h.saved.length, 0)
|
||||
assert.match(lib.exportFailureMessage(result), /没有数据/)
|
||||
})
|
||||
|
||||
test('a failing page stops the export without saving', async () => {
|
||||
const h = harness(450, { failPage: 3 })
|
||||
const result = await h.run()
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.reason, 'page_failed')
|
||||
assert.equal(result.page, 3)
|
||||
assert.deepEqual(h.calls, [1, 2, 3])
|
||||
assert.equal(h.saved.length, 0)
|
||||
assert.equal(lib.exportFailureMessage(result), '导出失败:第 3 页读取失败,未生成文件')
|
||||
const first = harness(10, { failPage: 1 })
|
||||
assert.equal((await first.run()).page, 1)
|
||||
assert.equal(first.saved.length, 0)
|
||||
})
|
||||
|
||||
test('enrich runs per batch (not per row), counts in progress, and a failure saves nothing', async () => {
|
||||
const batches = []
|
||||
const h = harness(450, { pageSize: 200, extra: { enrichBatchSize: 200, enrich: async items => { batches.push(items.length); return items.map(item => ({ ...item, id: item.id * 10 })) } } })
|
||||
const result = await h.run()
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(batches, [200, 200, 50])
|
||||
assert.deepEqual(h.saved[0].data[0], [10])
|
||||
assert.deepEqual(h.progress[h.progress.length - 1], [6, 6])
|
||||
const bad = harness(450, { pageSize: 200, extra: { enrich: async () => { throw new Error('boom') } } })
|
||||
const failed = await bad.run()
|
||||
assert.equal(failed.reason, 'enrich_failed')
|
||||
assert.equal(bad.saved.length, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user