fix(android): locate color images from raw cards (#133)

This commit is contained in:
QiuSW
2026-08-29 10:42:01 +08:00
parent f84ea5df3b
commit 9b044ed0e5
3 changed files with 89 additions and 4 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 31
versionName = "0.9.18"
versionCode = 32
versionName = "0.9.19"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -243,6 +243,7 @@ object PddScreenParser {
val dimensions = buildList<VisibleDimension> {
headings.forEachIndexed { index, heading ->
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
val values = panelVisible.asSequence()
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
@@ -254,7 +255,7 @@ object PddScreenParser {
it.label,
it.enabled && !stateText.containsUnavailableWord(),
it,
colorImageBounds(it, visibleNodes),
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
)
}
.toList()
@@ -262,7 +263,7 @@ object PddScreenParser {
.map { (_, sameLabel) ->
sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) }
}
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey(heading.label, config, any { it.key == "color" }), heading.label, values))
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey, heading.label, values))
}
}
// Opening evidence is intentionally independent from whether the current
@@ -363,6 +364,18 @@ object PddScreenParser {
val prefix = "${node.path}/"
return nodes.filter { it.path.startsWith(prefix) }
}
private fun colorImageBoundsForOption(option: SnapshotNode, nodes: List<SnapshotNode>): NodeBounds? {
val byPath = nodes.associateBy(SnapshotNode::path)
val expectedLabel = option.label.replace(Regex("\\s+"), "").takeIf(String::isNotEmpty) ?: return null
var candidate = byPath[option.path] ?: return null
while (true) {
val description = candidate.contentDescription?.replace(Regex("\\s+"), "")
if (description == expectedLabel) {
colorImageBounds(candidate, nodes)?.let { return it }
}
candidate = candidate.parentPath?.let(byPath::get) ?: return null
}
}
internal fun colorImageBounds(option: SnapshotNode, nodes: List<SnapshotNode>): NodeBounds? {
val expected = option.contentDescription?.replace(Regex("\\s+"), "")?.takeIf(String::isNotEmpty) ?: return null
@@ -47,6 +47,78 @@ class PddProductDetailCollectorTest {
assertEquals(matching.bounds, PddScreenParser.colorImageBounds(card, listOf(card, matching, globalDuplicate, openLarge)))
}
@Test
fun `full parser locates color image from original card while keeping caption click target`() {
fun describedNode(
path: String,
parent: String?,
text: String?,
description: String?,
bounds: NodeBounds,
className: String,
clickable: Boolean = false,
scrollable: Boolean = false,
) = SnapshotNode(
path, parent, text, description, null, className, bounds,
clickable, scrollable, selected = false, checked = false, enabled = true, visible = true,
)
val targetBounds = NodeBounds(36, 500, 340, 620)
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("summary", "已选:", 20, 300, 700, 360),
node("title", "确认款式", 20, 370, 300, 410),
node("color-heading", "颜色分类", 20, 420, 300, 470),
describedNode("scroll", null, null, null, NodeBounds(0, 400, 1080, 1800), "androidx.recyclerview.widget.RecyclerView", scrollable = true),
describedNode("card", "scroll", null, " A色 ", NodeBounds(36, 500, 340, 650), "android.view.ViewGroup", clickable = true),
describedNode("card/image", "card", null, " A色 ", targetBounds, "android.widget.ImageView", clickable = true),
describedNode("card/big", "card", null, "打开大图", NodeBounds(280, 500, 340, 560), "android.widget.ImageView", clickable = true),
describedNode("card/caption", "card", "A色", null, NodeBounds(36, 620, 340, 650), "android.widget.TextView", clickable = true),
describedNode("other/image", "other", null, " A色 ", NodeBounds(382, 500, 698, 620), "android.widget.ImageView", clickable = true),
node("size-heading", "尺码", 20, 700, 300, 750),
node("size", "M", 36, 780, 220, 850, clickable = true),
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
val color = parsed.dimensions.first { it.key == "color" }.values.single()
val size = parsed.dimensions.first { it.key == "size" }.values.single()
assertEquals("card/caption", color.node.path)
assertEquals(targetBounds, color.imageBounds)
assertEquals(null, size.imageBounds)
}
@Test
fun `full parser leaves color image empty when original card has no description`() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("summary", "已选:", 20, 300, 700, 360),
node("title", "确认款式", 20, 370, 300, 410),
node("color-heading", "颜色分类", 20, 420, 300, 470),
node("scroll", "", 0, 400, 1080, 1800, scrollable = true),
node("card", "A色", 36, 500, 340, 650, clickable = true, parentPath = "scroll", className = "android.view.ViewGroup"),
node("card/image", "A色", 36, 500, 340, 620, clickable = true, parentPath = "card", className = "android.widget.ImageView"),
node("card/caption", "A色", 36, 620, 340, 650, clickable = true, parentPath = "card"),
node("size-heading", "尺码", 20, 700, 300, 750),
node("size", "M", 36, 780, 220, 850, clickable = true),
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
),
)
val color = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
.dimensions.first { it.key == "color" }.values.single()
assertEquals(null, color.imageBounds)
}
@Test
fun pageLevelSoldOutRequiresExactTextAndNoNormalProductEvidence() {
val soldOut = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(