feat(purchase): reuse probed PDD page (#219)

This commit is contained in:
QiuSW
2026-09-05 10:34:35 +08:00
parent 5859a819c8
commit e4051ed8df
12 changed files with 106 additions and 41 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 54
versionName = "0.9.41"
versionCode = 55
versionName = "0.9.42"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -83,6 +83,10 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// spec_probe already opened this task's URL and the server reserves
// the device while matching. Skip the whole navigation action,
// including its configured wait/swipe hooks, in phase two.
if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue
stepChanged(action.type.wireName)
val failure = when (action.type) {
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
@@ -256,13 +256,20 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
TaskDispatchDecision.RUN_PURCHASE -> {
cancelIdleReturn("收到新的采购任务")
releaseCollectionCooldownWakeLock()
schedulePurchaseTask(api, requireNotNull(purchaseTask), token)
return MANUAL_PURCHASE_TASK
}
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH -> {
val waitingTask = requireNotNull(purchaseTask)
cancelIdleReturn("等待采购规格匹配")
stateStore.update("ONLINE", "采购任务 #${waitingTask.taskId} 正在匹配规格", tokenStored = true)
updateNotification("采购任务 #${waitingTask.taskId} 等待规格匹配")
return MANUAL_PURCHASE_MATCH_PENDING
}
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> {
val ticket = requireNotNull(collectionCooldown)
showCollectionCooldown(ticket)
@@ -1074,6 +1081,7 @@ class AgentForegroundService : Service() {
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
const val MANUAL_PURCHASE_MATCH_PENDING = "purchase_match_pending"
const val MANUAL_BUSY = "busy"
const val MANUAL_CONFIG_REQUIRED = "config_required"
const val MANUAL_AUTH_ERROR = "auth_error"
@@ -99,13 +99,15 @@ internal object CollectionCooldownPolicy {
internal enum class TaskDispatchDecision {
RUN_PURCHASE,
WAIT_FOR_PURCHASE_MATCH,
WAIT_FOR_COLLECTION_COOLDOWN,
CHECK_COLLECTION,
}
internal object TaskDispatchPolicy {
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
else -> TaskDispatchDecision.CHECK_COLLECTION
}
@@ -86,15 +86,19 @@ class CollectionCooldownPolicyTest {
fun `purchase keeps priority while collection waits for cooldown`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false),
)
}
}
@@ -81,12 +81,12 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(1, openCount)
assertEquals(0, openCount)
assertEquals(2, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("打开", "选择规格", "黑色", "XL")))
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
assertTrue(pauses.contains(700))
assertFalse(pauses.contains(700))
}
@Test
@@ -404,8 +404,8 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `ambiguous browser target stops safely`() {
val driver = FakePurchaseDriver(duplicateOpen = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(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())
}
@@ -416,10 +416,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.FAILED),
openPddOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
}
@@ -429,10 +429,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(2, driver.openClickCount)
assertTrue(pauses.size <= 50)
}
@@ -443,8 +443,8 @@ class PurchaseRehearsalExecutorTest {
openClickResults = MutableList(10) { FreshActionResult.FAILED },
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(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..5)
@@ -523,9 +523,7 @@ class PurchaseRehearsalExecutorTest {
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
)
// One 100ms pause belongs to the existing open-product foreground poll;
// the diagnostic proves the entry-ready loop itself used exactly 20.
assertEquals(21, pauses.count { it == 100L })
assertEquals(20, pauses.count { it == 100L })
assertFalse(diagnostics.single().contains("选择规格"))
}