fix(agent): 支持不可滚动规格面板 (#202)

This commit is contained in:
QiuSW
2026-09-03 11:44:04 +08:00
parent 415d1ff2ad
commit bd4bc5f4f0
4 changed files with 40 additions and 4 deletions
@@ -306,6 +306,14 @@ object PddScreenParser {
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
// Some PDD builds expose the complete selector as non-scrollable.
// Accept it only when two parsed dimensions and selectable values
// coexist with the selected summary, unique quantity controls and
// the unique lower-page order action; generic product/review pages
// cannot satisfy this combined evidence.
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
dimensions.sumOf { it.values.size } >= 2 && hasSelectionSummary &&
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
else -> SpecPanelType.UNKNOWN
}
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
@@ -51,6 +51,7 @@ class PurchaseRehearsalExecutor(
private val probeSpecs: () -> String?,
private val pause: (Long) -> Unit = Thread::sleep,
private val stepChanged: (String) -> Unit = {},
private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
) {
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
@@ -123,7 +124,7 @@ class PurchaseRehearsalExecutor(
}
return failure
}
applyPostAction(action)?.let { return it }
applyPostAction(input, action)?.let { return it }
}
if (input.phase == "spec_probe") return failure("PURCHASE_RULE_INVALID", "规格探测任务缺少 probeSpecs 动作")
return if (input.executionMode == "rehearsal") PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止", actualUnitPriceCent = observedPrice)
@@ -288,6 +289,7 @@ class PurchaseRehearsalExecutor(
}
repeat(30) {
screen = currentScreen(input)
panelDiagnostic(panelEvidence(screen))
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
@@ -296,6 +298,11 @@ class PurchaseRehearsalExecutor(
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板")
}
private fun panelEvidence(screen: ParsedPddScreen): String =
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List<String>): Boolean {
val prefix = "${candidate.path}/"
return (sequenceOf(candidate) + screen.sourceNodes.asSequence().filter { it.path.startsWith(prefix) })
@@ -590,9 +597,17 @@ class PurchaseRehearsalExecutor(
return null
}
private fun applyPostAction(action: PurchaseAction): PurchaseExecutionOutcome? {
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// The stock purchase rule asks to reveal additional selector rows after
// opening the sheet. A fully-evidenced non-scrollable selector has no
// scroll target, and treating that absence as an action failure blocks
// an otherwise safe exact-spec flow. Keep all other configured swipes
// mandatory; this exception is limited to that confirmed panel state.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
) return null
repeat(swipe.count) { index ->
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
@@ -473,6 +473,7 @@ class AgentForegroundService : Service() {
openLink = { PddLinkLauncher(this).open(it) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -418,6 +418,16 @@ class PurchaseRehearsalExecutorTest {
assertEquals(50, pauses.size)
}
@Test
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
}
@Test
fun `transient sold out page recovers before opening specs`() {
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
@@ -732,6 +742,8 @@ class PurchaseRehearsalExecutorTest {
private val loseEvidenceAfterPull: Boolean = false,
private val unavailableSizes: Set<String> = emptySet(),
allSpecsUnavailable: Boolean = false,
private val nonScrollablePanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
) : PurchaseUiDriver {
var browser = false
var panel = false
@@ -802,8 +814,8 @@ class PurchaseRehearsalExecutorTest {
node("content", "", 0, 0, 1080, 2200),
node("price", "¥${priceCent / 100}.${(priceCent % 100).toString().padStart(2, '0')}", 20, 300, 300, 360),
node("title", "确认款式", 20, 396, 300, 430),
node("scroll", "", 0, 400, 1080, 950, scrollable = true),
)
if (!nonScrollablePanel) nodes += node("scroll", "", 0, 400, 1080, 950, scrollable = true)
if (!hideSummary) {
nodes += node("selected", "已选 $displayedSummary", 20, 365, 700, 395)
}
@@ -912,7 +924,7 @@ class PurchaseRehearsalExecutorTest {
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
swipeCount++
if (direction == SwipeDirection.UP) upSwipeCount++
return true
return purchaseSwipeSucceeds
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =