fix(agent): recognize selected spec panels without summary prefix (#231)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+28
-1
@@ -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,
|
||||
|
||||
+26
-5
@@ -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(),
|
||||
"",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<FreshActionResult> = 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
|
||||
|
||||
Reference in New Issue
Block a user