feat(#124): add color discovery diagnostics
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "cn.ilapage.goauto.agent"
|
applicationId = "cn.ilapage.goauto.agent"
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 24
|
versionCode = 25
|
||||||
versionName = "0.9.11"
|
versionName = "0.9.12"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
+131
-5
@@ -881,12 +881,53 @@ class PddProductDetailCollector(
|
|||||||
missing: MutableSet<String>,
|
missing: MutableSet<String>,
|
||||||
unsupported: MutableSet<String>,
|
unsupported: MutableSet<String>,
|
||||||
): PddCollectorResult? {
|
): PddCollectorResult? {
|
||||||
|
val diagnosticStartedAt = now()
|
||||||
|
val diagnosticRowValues = linkedMapOf<Int, MutableSet<String>>()
|
||||||
|
val diagnosticParsedValues = linkedSetOf<String>()
|
||||||
|
var diagnosticNonClickableCandidates = 0
|
||||||
|
var diagnosticSelectedValues = 0
|
||||||
|
var diagnosticHorizontalSwipes = 0
|
||||||
|
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
||||||
|
|
||||||
|
fun observeColorDiscovery(screen: ParsedPddScreen, rows: List<List<VisibleSpecValue>>) {
|
||||||
|
rows.forEachIndexed { index, row ->
|
||||||
|
diagnosticRowValues.getOrPut(index) { linkedSetOf() }.addAll(row.map(VisibleSpecValue::text))
|
||||||
|
}
|
||||||
|
val values = rows.flatten()
|
||||||
|
diagnosticParsedValues += values.map(VisibleSpecValue::text)
|
||||||
|
diagnosticSelectedValues = maxOf(
|
||||||
|
diagnosticSelectedValues,
|
||||||
|
values.count { it.node.selected || it.node.checked },
|
||||||
|
)
|
||||||
|
diagnosticNonClickableCandidates = maxOf(
|
||||||
|
diagnosticNonClickableCandidates,
|
||||||
|
visibleNonClickableColorCandidateCount(screen, config, values.map(VisibleSpecValue::text).toSet()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishColorDiscovery(reason: AgentDiagnosticReason = diagnosticTermination) {
|
||||||
|
recordColorDiscovery(
|
||||||
|
reason = reason,
|
||||||
|
rowValueCounts = diagnosticRowValues.mapValues { it.value.size },
|
||||||
|
parsedValueCount = diagnosticParsedValues.size,
|
||||||
|
nonClickableCandidateCount = diagnosticNonClickableCandidates,
|
||||||
|
selectedValueCount = diagnosticSelectedValues,
|
||||||
|
horizontalSwipes = diagnosticHorizontalSwipes,
|
||||||
|
elapsedMs = now() - diagnosticStartedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
||||||
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
|
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
|
||||||
val initial = parse(goodsId, config, evidence)
|
val initial = parse(goodsId, config, evidence)
|
||||||
initial.problem?.let { return failure(it.code, it.message) }
|
initial.problem?.let { return failure(it.code, it.message) }
|
||||||
val rowCount = colorRows(initial).size
|
val initialRows = colorRows(initial)
|
||||||
if (rowCount == 0) return null
|
observeColorDiscovery(initial, initialRows)
|
||||||
|
val rowCount = initialRows.size
|
||||||
|
if (rowCount == 0) {
|
||||||
|
finishColorDiscovery(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||||
|
return null
|
||||||
|
}
|
||||||
val attempted = mutableSetOf<String>()
|
val attempted = mutableSetOf<String>()
|
||||||
for (rowIndex in 0 until rowCount) {
|
for (rowIndex in 0 until rowCount) {
|
||||||
val moveRight = rowIndex % 2 == 0
|
val moveRight = rowIndex % 2 == 0
|
||||||
@@ -902,6 +943,7 @@ class PddProductDetailCollector(
|
|||||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||||
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
||||||
val rows = colorRows(screen)
|
val rows = colorRows(screen)
|
||||||
|
observeColorDiscovery(screen, rows)
|
||||||
if (rowIndex >= rows.size) {
|
if (rowIndex >= rows.size) {
|
||||||
// Some PDD builds remove the selected option from the
|
// Some PDD builds remove the selected option from the
|
||||||
// clickable accessibility nodes and immediately reflow
|
// clickable accessibility nodes and immediately reflow
|
||||||
@@ -909,6 +951,7 @@ class PddProductDetailCollector(
|
|||||||
// successful selection is therefore not evidence that
|
// successful selection is therefore not evidence that
|
||||||
// its values were missed. Real gaps are still reported
|
// its values were missed. Real gaps are still reported
|
||||||
// below as missing colors or prices.
|
// below as missing colors or prices.
|
||||||
|
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
currentRow = rows[rowIndex].sortedBy { it.node.bounds.left }
|
currentRow = rows[rowIndex].sortedBy { it.node.bounds.left }
|
||||||
@@ -937,18 +980,101 @@ class PddProductDetailCollector(
|
|||||||
val signature = optionSignature(currentRow)
|
val signature = optionSignature(currentRow)
|
||||||
stable = if (signature == previous) stable + 1 else 0
|
stable = if (signature == previous) stable + 1 else 0
|
||||||
previous = signature
|
previous = signature
|
||||||
if (stable >= config.limits.getValue("stableEdgeReads") || pass == config.limits.getValue("specHorizontalSwipes")) break
|
if (stable >= config.limits.getValue("stableEdgeReads")) {
|
||||||
val anchor = currentRow.firstOrNull()?.node ?: break
|
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) {
|
||||||
|
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pass == config.limits.getValue("specHorizontalSwipes")) {
|
||||||
|
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND) {
|
||||||
|
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val anchor = currentRow.firstOrNull()?.node
|
||||||
|
if (anchor == null) {
|
||||||
|
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
|
||||||
|
break
|
||||||
|
}
|
||||||
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
||||||
if (!driver.swipeSpec(direction, anchor)) break
|
if (!driver.swipeSpec(direction, anchor)) {
|
||||||
|
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
|
||||||
|
break
|
||||||
|
}
|
||||||
|
diagnosticHorizontalSwipes++
|
||||||
pause(350)
|
pause(350)
|
||||||
}
|
}
|
||||||
val naturalOrder = rowColors.entries.toList().let { if (moveRight) it else it.reversed() }
|
val naturalOrder = rowColors.entries.toList().let { if (moveRight) it else it.reversed() }
|
||||||
naturalOrder.forEach { (text, available) -> colors[text] = colors[text] == true || available }
|
naturalOrder.forEach { (text, available) -> colors[text] = colors[text] == true || available }
|
||||||
}
|
}
|
||||||
|
finishColorDiscovery()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun recordColorDiscovery(
|
||||||
|
reason: AgentDiagnosticReason,
|
||||||
|
rowValueCounts: Map<Int, Int>,
|
||||||
|
parsedValueCount: Int,
|
||||||
|
nonClickableCandidateCount: Int,
|
||||||
|
selectedValueCount: Int,
|
||||||
|
horizontalSwipes: Int,
|
||||||
|
elapsedMs: Long,
|
||||||
|
) {
|
||||||
|
if (taskId <= 0) return
|
||||||
|
fun record(metricReason: AgentDiagnosticReason, attempt: Int = 0, count: Int) {
|
||||||
|
diagnostic(
|
||||||
|
AgentDiagnosticEvent(
|
||||||
|
taskId = taskId,
|
||||||
|
stage = AgentDiagnosticStage.COLOR_DISCOVERY,
|
||||||
|
reason = metricReason,
|
||||||
|
attempt = attempt,
|
||||||
|
elapsedMs = elapsedMs,
|
||||||
|
candidateCount = count,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rowValueCounts.forEach { (rowIndex, count) ->
|
||||||
|
record(AgentDiagnosticReason.COLOR_ROW_VALUE_COUNT, attempt = rowIndex + 1, count = count)
|
||||||
|
}
|
||||||
|
record(AgentDiagnosticReason.COLOR_FOUND, count = parsedValueCount)
|
||||||
|
record(AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE, count = nonClickableCandidateCount)
|
||||||
|
record(AgentDiagnosticReason.COLOR_SELECTED_VALUE_COUNT, count = selectedValueCount)
|
||||||
|
record(AgentDiagnosticReason.COLOR_HORIZONTAL_SWIPE_COUNT, count = horizontalSwipes)
|
||||||
|
record(AgentDiagnosticReason.COLOR_VERTICAL_SWIPE_COUNT, count = 0)
|
||||||
|
if (reason != AgentDiagnosticReason.COLOR_FOUND) {
|
||||||
|
record(reason, attempt = horizontalSwipes, count = parsedValueCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun visibleNonClickableColorCandidateCount(
|
||||||
|
screen: ParsedPddScreen,
|
||||||
|
config: PddCollectorConfig,
|
||||||
|
parsedLabels: Set<String>,
|
||||||
|
): Int {
|
||||||
|
val visible = screen.sourceNodes.filter(SnapshotNode::visible)
|
||||||
|
val compactColorAliases = config.colorAliases.map { it.replace(" ", "") }
|
||||||
|
val heading = visible.asSequence()
|
||||||
|
.filterNot(SnapshotNode::clickable)
|
||||||
|
.filter { node -> compactColorAliases.any { alias -> node.label.replace(" ", "").contains(alias) } }
|
||||||
|
.minByOrNull { it.bounds.top } ?: return 0
|
||||||
|
val allHeadingAliases = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
|
||||||
|
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }
|
||||||
|
val lowerBound = visible.asSequence()
|
||||||
|
.filterNot(SnapshotNode::clickable)
|
||||||
|
.filter { it.bounds.top >= heading.bounds.bottom }
|
||||||
|
.filter { node -> allHeadingAliases.any { alias -> node.label.replace(" ", "").contains(alias) } }
|
||||||
|
.minOfOrNull { it.bounds.top } ?: Int.MAX_VALUE
|
||||||
|
val paths = visible.map(SnapshotNode::path).toSet()
|
||||||
|
return visible.asSequence()
|
||||||
|
.filter { !it.clickable && it.enabled && it.label.isNotBlank() }
|
||||||
|
.filter { it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lowerBound }
|
||||||
|
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
|
||||||
|
.filterNot { it.label in parsedLabels }
|
||||||
|
.filterNot { candidate -> paths.any { it.startsWith("${candidate.path}/") } }
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
private fun moveSpecPanelToTop(
|
private fun moveSpecPanelToTop(
|
||||||
goodsId: String,
|
goodsId: String,
|
||||||
config: PddCollectorConfig,
|
config: PddCollectorConfig,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.BuildConfig
|
|||||||
enum class AgentDiagnosticStage {
|
enum class AgentDiagnosticStage {
|
||||||
DETAIL_ENTRY,
|
DETAIL_ENTRY,
|
||||||
SPEC_PANEL_ENTRY,
|
SPEC_PANEL_ENTRY,
|
||||||
|
COLOR_DISCOVERY,
|
||||||
SIZE_DISCOVERY,
|
SIZE_DISCOVERY,
|
||||||
PAGE_STABILITY,
|
PAGE_STABILITY,
|
||||||
SHARE_CLICK,
|
SHARE_CLICK,
|
||||||
@@ -35,6 +36,17 @@ enum class AgentDiagnosticReason {
|
|||||||
SIZE_SCAN_LIMIT,
|
SIZE_SCAN_LIMIT,
|
||||||
SIZE_CONTAINER_UNAVAILABLE,
|
SIZE_CONTAINER_UNAVAILABLE,
|
||||||
SIZE_SWIPE_FAILED,
|
SIZE_SWIPE_FAILED,
|
||||||
|
COLOR_FOUND,
|
||||||
|
COLOR_EDGE_REACHED,
|
||||||
|
COLOR_SCAN_LIMIT,
|
||||||
|
COLOR_ROW_REFLOWED,
|
||||||
|
COLOR_SWIPE_FAILED,
|
||||||
|
COLOR_CONTAINER_UNAVAILABLE,
|
||||||
|
COLOR_VALUES_NOT_CLICKABLE,
|
||||||
|
COLOR_ROW_VALUE_COUNT,
|
||||||
|
COLOR_SELECTED_VALUE_COUNT,
|
||||||
|
COLOR_HORIZONTAL_SWIPE_COUNT,
|
||||||
|
COLOR_VERTICAL_SWIPE_COUNT,
|
||||||
PACKAGE_MISMATCH,
|
PACKAGE_MISMATCH,
|
||||||
ACTIVITY_MISMATCH,
|
ACTIVITY_MISMATCH,
|
||||||
SELECTOR_MISMATCH,
|
SELECTOR_MISMATCH,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import cn.ilapage.goauto.agent.automation.UiSnapshot
|
|||||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
||||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||||
|
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -535,6 +536,62 @@ class PddProductDetailCollectorTest {
|
|||||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
|
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `color discovery records only bounded aggregate evidence`() {
|
||||||
|
val colors = listOf("A色", "B色", "C色", "D色")
|
||||||
|
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||||
|
val driver = FakeCollectorDriver(
|
||||||
|
colors = colors,
|
||||||
|
prices = colors.associateWith { 1000L },
|
||||||
|
rowSize = 2,
|
||||||
|
nonClickableColorCandidates = listOf("隐藏候选"),
|
||||||
|
)
|
||||||
|
var clock = 0L
|
||||||
|
|
||||||
|
val result = PddProductDetailCollector(
|
||||||
|
driver,
|
||||||
|
{ clock },
|
||||||
|
{ clock += it },
|
||||||
|
taskId = 124,
|
||||||
|
diagnostic = events::add,
|
||||||
|
).collect(GOODS_ID, rule())
|
||||||
|
|
||||||
|
assertTrue(result.successful)
|
||||||
|
val colorEvents = events.filter { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||||
|
assertEquals(listOf(2, 2), colorEvents.filter { it.reason == AgentDiagnosticReason.COLOR_ROW_VALUE_COUNT }.map { it.candidateCount })
|
||||||
|
assertEquals(4, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_FOUND }.candidateCount)
|
||||||
|
assertEquals(1, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE }.candidateCount)
|
||||||
|
assertEquals(1, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_SELECTED_VALUE_COUNT }.candidateCount)
|
||||||
|
assertTrue(requireNotNull(colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_HORIZONTAL_SWIPE_COUNT }.candidateCount) > 0)
|
||||||
|
assertEquals(0, colorEvents.single { it.reason == AgentDiagnosticReason.COLOR_VERTICAL_SWIPE_COUNT }.candidateCount)
|
||||||
|
assertTrue(colorEvents.any { it.reason == AgentDiagnosticReason.COLOR_EDGE_REACHED })
|
||||||
|
val persistedText = colorEvents.joinToString()
|
||||||
|
colors.forEach { assertFalse(persistedText.contains(it)) }
|
||||||
|
assertFalse(persistedText.contains("隐藏候选"))
|
||||||
|
assertFalse(persistedText.contains("10.00"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `color diagnostic write failure does not change collection result`() {
|
||||||
|
var failureCount = 0
|
||||||
|
val recorder = SafeAgentDiagnosticRecorder(
|
||||||
|
persist = { error("diagnostic database unavailable") },
|
||||||
|
onFailure = { failureCount++ },
|
||||||
|
)
|
||||||
|
var clock = 0L
|
||||||
|
|
||||||
|
val result = PddProductDetailCollector(
|
||||||
|
FakeCollectorDriver(),
|
||||||
|
{ clock },
|
||||||
|
{ clock += it },
|
||||||
|
taskId = 124,
|
||||||
|
diagnostic = recorder::record,
|
||||||
|
).collect(GOODS_ID, rule())
|
||||||
|
|
||||||
|
assertTrue(result.successful)
|
||||||
|
assertTrue(failureCount > 0)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun selectedColorsDisappearingAndReflowingDoNotCreateFalseMissingRows() {
|
fun selectedColorsDisappearingAndReflowingDoNotCreateFalseMissingRows() {
|
||||||
val colors = listOf("A色", "B色", "C色")
|
val colors = listOf("A色", "B色", "C色")
|
||||||
@@ -1093,6 +1150,7 @@ class PddProductDetailCollectorTest {
|
|||||||
private val orderConfirmationAfterEntry: Boolean = false,
|
private val orderConfirmationAfterEntry: Boolean = false,
|
||||||
private val orderRestorePagesBeforeSpecs: Int = 0,
|
private val orderRestorePagesBeforeSpecs: Int = 0,
|
||||||
private val sizeHeadingLabel: String = "尺码",
|
private val sizeHeadingLabel: String = "尺码",
|
||||||
|
private val nonClickableColorCandidates: List<String> = emptyList(),
|
||||||
) : PddCollectorDriver {
|
) : PddCollectorDriver {
|
||||||
var captureCount = 0
|
var captureCount = 0
|
||||||
var clickCount = 0
|
var clickCount = 0
|
||||||
@@ -1182,6 +1240,17 @@ class PddProductDetailCollectorTest {
|
|||||||
parentPath = "scroll",
|
parentPath = "scroll",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
nonClickableColorCandidates.forEachIndexed { index, label ->
|
||||||
|
nodes += node(
|
||||||
|
"scroll/non-clickable-color-$index",
|
||||||
|
label,
|
||||||
|
30 + index * 230,
|
||||||
|
650,
|
||||||
|
220 + index * 230,
|
||||||
|
690,
|
||||||
|
parentPath = "scroll",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll")
|
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, 700, 300, 750, parentPath = "scroll")
|
||||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||||
|
|||||||
Reference in New Issue
Block a user