diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 043d059..c60474d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 69 - versionName = "0.9.56" + versionCode = 70 + versionName = "0.9.57" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index f318606..9aefeee 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -306,9 +306,15 @@ object PddScreenParser { // Opening evidence is intentionally independent from whether the current // viewport still exposes a clickable spec value. PDD may hide the only // selected value or restore a previously scrolled confirmation panel. + val structuredSelectionPanel = headedPanelScrollable != null && headings.size >= 2 && + dimensions.any { it.values.isNotEmpty() } && hasClose && + hasQuantityControls && hasPaymentArea && hasOrderSubmitAction + // Some selected-spec panels omit both the "已选" prefix and a confirm + // button. Keep the same structural evidence required for checkout. val specPanelType = when { quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION + structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE 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 @@ -322,6 +328,27 @@ object PddScreenParser { else -> SpecPanelType.UNKNOWN } val panelOpen = specPanelType != SpecPanelType.UNKNOWN + val unprefixedSummary = if (structuredSelectionPanel) { + val quantity = quantityInputs.single() + val knownColors = dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } + val byPath = visibleNodes.associateBy { it.path } + var region = quantity.parentPath?.let(byPath::get) + var summary: String? = null + while (region != null && region.bounds.bottom <= headedPanelScrollable!!.bounds.top) { + val candidates = visible.filter { node -> + node.path.startsWith("${region!!.path}/") && !node.clickable && + node.className?.endsWith("TextView") == true && + node.bounds.bottom <= quantity.bounds.top && node.label.length <= 160 && + knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) } + }.map { it.label }.distinct() + if (candidates.isNotEmpty()) { + summary = candidates.singleOrNull() + break + } + region = region.parentPath?.let(byPath::get) + } + summary + } else null val firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE val price = visible.asSequence() .filter { it.bounds.top < firstHeadingTop } @@ -373,7 +400,7 @@ object PddScreenParser { selectedSummary = labels.firstOrNull { val compact = it.replace(" ", "") textAliases.selection.selectedPrefixes.any(compact::startsWith) - }, + } ?: unprefixedSummary, priceCent = price, specPanelOpen = panelOpen, specPanelType = specPanelType, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index 00361cc..fd2bcb5 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -613,10 +613,16 @@ class PurchaseRehearsalExecutor( private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean { val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values } + if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true + if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates) } + private fun fullSummaryTargetMatches(summary: String?, target: String): Boolean = + summary != null && Regex("(^|[\\s,,、/|;;::])" + Regex.escape(target) + "($|[\\s,,、/|;;::])") + .containsMatchIn(summary) + private fun summarySelectionMatches( summary: String?, dimension: String, @@ -648,7 +654,8 @@ class PurchaseRehearsalExecutor( if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token } return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf { - tokenCandidates.size == 1 && tokenCandidates.single().text == target + (tokenCandidates.size == 1 && tokenCandidates.single().text == target) || + (tokenCandidates.isEmpty() && screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) } } @@ -787,13 +794,27 @@ class PurchaseRehearsalExecutor( * Every gesture is followed by a fresh parse, and unchanged evidence ends * that direction early. */ + private fun panelRecognitionFailure(screen: ParsedPddScreen, dimension: String): PurchaseExecutionOutcome = + failure("RULE_NOT_MATCHED", + "未能识别当前商品规格面板 [dimension=$dimension;panel=${screen.specPanelType};headings=${screen.panelHeadingCount};options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};orderAction=${screen.hasOrderSubmitAction}]") + + private fun stableSelectionScreen(input: PurchaseExecutionInput): ParsedPddScreen { + var screen = currentScreen(input) + repeat(2) { + if (screen.specPanelOpen || screen.problem != null) return screen + pause(SPEC_SELECTION_POLL_MILLIS) + screen = currentScreen(input) + } + return screen + } + private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup { data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?) fun inspect(): Inspection { - val screen = currentScreen(input) + val screen = stableSelectionScreen(input) screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) } if (!screen.specPanelOpen) { - return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null) + return Inspection(SpecLookup(failure = panelRecognitionFailure(screen, dimension)), "", null) } val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values } val exact = dimensionValues.filter { it.text == target } @@ -850,13 +871,13 @@ class PurchaseRehearsalExecutor( ) fun inspect(): ColorInspection { - val screen = currentScreen(input) + val screen = stableSelectionScreen(input) screen.problem?.let { return ColorInspection(SpecLookup(failure = failure(it.code, it.message)), screen, emptyList(), "") } if (!screen.specPanelOpen) { return ColorInspection( - SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), + SpecLookup(failure = panelRecognitionFailure(screen, dimension)), screen, emptyList(), "", diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index e36e63d..c7670c6 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -26,6 +26,45 @@ import org.junit.Assert.assertTrue import org.junit.Test class PddProductDetailCollectorTest { + private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( + node("root", "", 0, 0, 1080, 2376), + node("close", "关闭", 970, 270, 1050, 350, clickable = true), + node("info", "", 0, 770, 1080, 1157), + node("info/summary", "米白色(有里布) 2XL 建议131到150斤", 396, 878, 1053, 992, parentPath = "info"), + node("info/quantity", "1", 480, 1046, 561, 1121, className = "android.widget.EditText", parentPath = "info"), + node("minus", "减少数量", 396, 1046, 470, 1121, clickable = true), + node("plus", "增加数量", 570, 1046, 645, 1121, clickable = true), + node("scroll", "", 0, 1294, 1080, 2079, scrollable = true), + node("scroll/color-title", "颜色分类", 36, 1319, 216, 1380, parentPath = "scroll"), + node("scroll/color", "米白色(有里布)", 36, 1405, 352, 1808, clickable = true, selected = true, parentPath = "scroll"), + node("scroll/size-title", "尺码", 36, 1858, 126, 1911, parentPath = "scroll"), + node("scroll/size", "XL 建议111到130斤", 36, 1930, 440, 2015, clickable = true, parentPath = "scroll"), + node("payment", "微信支付", 112, 2100, 929, 2157), + node("submit", "提交订单", 375, 2225, 705, 2284, clickable = true), + )) + + @Test fun `prefixless selected panel retains structural recognition and product summary`() { + val parsed = PddScreenParser.parse(prefixlessPanel(), config(), GOODS_ID, evidence()) + assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType) + assertEquals("米白色(有里布) 2XL 建议131到150斤", parsed.selectedSummary) + } + + @Test fun `prefixless panel requires quantity and order evidence`() { + for (missing in listOf("plus", "submit", "payment")) { + val snapshot = prefixlessPanel() + val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing }), config(), GOODS_ID, evidence()) + assertFalse(parsed.specPanelOpen) + assertEquals(null, parsed.selectedSummary) + } + } + + @Test fun `ambiguous product summaries are not used`() { + val snapshot = prefixlessPanel() + val extra = node("info/other", "米白色(有里布) XL 建议111到130斤", 396, 800, 1053, 870, parentPath = "info") + val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes + extra), config(), GOODS_ID, evidence()) + assertEquals(null, parsed.selectedSummary) + } + @Test fun `parser removes only trailing size price and keeps raw evidence`() { val snapshot = UiSnapshot( diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index f87e4ec..f01f4d6 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -22,6 +22,27 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseRehearsalExecutorTest { + @Test + 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 = {}) + .execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(outcome.message, "rehearsal_completed", outcome.resultType) + assertEquals(1, driver.clicked.count { it == target }) + } + + @Test + fun `same size token with different full range cannot establish selection`() { + val target = "2XL 建议131到150斤" + val driver = FakePurchaseDriver( + sizes = listOf(target), hideSizeAfterSelection = true, + selectedSizeSummaryOverride = "2XL 建议151到170斤", + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("failed", outcome.resultType) + } @Test fun `spec gesture policy excludes irreversible and out of bounds targets`() { fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode( @@ -1187,6 +1208,7 @@ class PurchaseRehearsalExecutorTest { private val openReviewOnBottomClick: Boolean = false, private val reviewBackSucceeds: Boolean = true, private val hiddenSizeUntilUpSwipes: Int = 0, + private val hideSizeAfterSelection: Boolean = false, private val revealGridSizeAfterUpSwipes: Int? = null, private val openClickResults: MutableList = mutableListOf(), private val openPddOnFailedClick: Boolean = false, @@ -1325,7 +1347,7 @@ class PurchaseRehearsalExecutorTest { )) } val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored - val hideSize = hideSizeAfterQuantitySet && quantity == 2L + val hideSize = (hideSizeAfterQuantitySet && quantity == 2L) || (hideSizeAfterSelection && size != null) val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) { selectedSummaryOverrideAfterQuantitySet