feat(android): configure continuous collection back count #250

This commit is contained in:
QiuSW
2026-09-09 16:16:37 +08:00
parent ade48a7a38
commit 37e8b567e8
9 changed files with 267 additions and 276 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 84
versionName = "0.9.71"
versionCode = 85
versionName = "0.9.72"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -1,101 +0,0 @@
package cn.ilapage.goauto.agent.automation
/** Used only after an acknowledged, complete temporary collection, while its local lock is held. */
internal class PddCollectionHomeNavigator(
private val driver: PddCollectorDriver,
private val pause: (Long) -> Unit = Thread::sleep,
) {
var reason: String = "not_started"
private set
var actions: Int = 0
private set
private fun finish(value: String, success: Boolean = false): Boolean {
reason = value
return success
}
fun returnHome(rule: CollectionRule): Boolean = runCatching {
actions = 0
var screen = driver.capture()
repeat(MAX_ACTIONS) {
if (!safe(screen)) return finish("unsafe_page")
if (isHome(screen)) return finish("home_confirmed", true)
val before = signature(screen)
val photoSearch = isPhotoSearchLayer(screen)
val home = if (photoSearch) null else homeTab(screen)
actions++
if (home != null) {
if (driver.clickFresh(home) != FreshActionResult.SUCCESS) return finish("home_click_failed")
} else {
val parsed = rule.collector?.let { PddScreenParser.parse(screen, it, "", rule.pageEvidence) }
val knownProduct = parsed != null && parsed.pageEvidenceMatched &&
(parsed.specPanelOpen || parsed.specEntry != null)
if (!photoSearch && !knownProduct && topBack(screen) == null) {
actions--
return finish("unknown_page")
}
if (!driver.back()) return finish("back_failed")
}
// Read after each individual action; never send a sequence of blind Back events.
for (read in 1..3) {
pause(500)
screen = driver.capture()
if (!safe(screen)) return finish("unsafe_page")
if (isHome(screen)) return finish("home_confirmed", true)
if (signature(screen) != before) break
}
if (signature(screen) == before) return finish(if (photoSearch) "search_back_unchanged" else "page_unchanged")
}
finish("action_limit")
}.getOrElse { finish("navigation_exception") }
private fun topBack(screen: UiSnapshot): SnapshotNode? {
val height = screen.nodes.maxOfOrNull { it.bounds.bottom } ?: return null
return screen.nodes.filter { it.visible && it.enabled && it.label == "返回" &&
it.bounds.height > 0 && it.bounds.centerY < height / 4 }.singleOrNull()
}
private fun signature(screen: UiSnapshot) = screen.activityName to screen.nodes.filter { it.visible }
.map { listOf(it.path, it.label, it.bounds.toString(), it.selected.toString()) }
companion object {
const val MAX_ACTIONS = 4
private const val PDD = "com.xunmeng.pinduoduo"
internal fun isPhotoSearchLayer(screen: UiSnapshot): Boolean {
val nodes = screen.nodes.filter { it.visible && it.enabled }
val height = nodes.maxOfOrNull { it.bounds.bottom } ?: return false
val width = nodes.maxOfOrNull { it.bounds.right } ?: return false
if (height <= 0 || width <= 0) return false
return nodes.any { it.label == "拍照搜索" && it.className == "android.widget.FrameLayout" &&
!it.clickable && it.bounds.height >= height * 4 / 5 && it.bounds.width >= width * 4 / 5 }
}
internal fun safe(screen: UiSnapshot): Boolean = screen.packageName == PDD &&
!screen.activityName.isNullOrBlank() &&
PddPageClassifier.classify(screen.packageName, screen.activityName, screen.nodes.filter { it.visible }.map { it.label }) == null &&
screen.nodes.none { it.visible && listOf("订单详情", "订单待支付", "确认付款", "立即支付", "编辑收货地址", "新增收货地址", "地址管理").any(it.label::contains) }
// Observed on the target phone: selected Home text inside an unselected clickable tab,
// beside Chat and Profile; a separate top camera-search control proves the home content.
internal fun homeTab(screen: UiSnapshot): SnapshotNode? {
val nodes = screen.nodes.filter { it.visible && it.enabled && it.bounds.height > 0 }
val height = nodes.maxOfOrNull { it.bounds.bottom } ?: return null
val bottom = nodes.filter { it.bounds.centerY > height * 4 / 5 }
if (!setOf("聊天", "个人中心").all { name -> bottom.any { it.label == name } }) return null
val text = bottom.filter { it.label == "首页" && !it.clickable }.singleOrNull() ?: return null
val parent = nodes.singleOrNull { it.path == text.parentPath && it.clickable } ?: return null
if (parent.bounds.centerY <= height * 4 / 5 || parent.bounds.width > (nodes.maxOfOrNull { it.bounds.right } ?: 0) / 3) return null
return text
}
internal fun isHome(screen: UiSnapshot): Boolean {
if (!safe(screen) || isPhotoSearchLayer(screen)) return false
val home = homeTab(screen) ?: return false
val height = screen.nodes.maxOfOrNull { it.bounds.bottom } ?: return false
return home.selected && screen.nodes.any { it.visible && it.enabled && it.clickable &&
it.label == "拍照搜索" && it.bounds.height > 0 && it.bounds.centerY < height / 4 }
}
}
}
@@ -0,0 +1,58 @@
package cn.ilapage.goauto.agent.automation
/** Complete temporary collections only; caller retains the existing task lock. */
internal class PddCollectionReturnNavigator(
private val driver: PddCollectorDriver,
private val openAgent: () -> Boolean,
private val currentPackage: () -> String?,
private val pause: (Long) -> Unit = Thread::sleep,
) {
var reason = "not_started"
private set
var actions = 0
private set
fun returnToAgent(count: Int): Boolean {
actions = 0
if (count !in 1..5) { reason = "invalid_count"; return false }
try {
reason = "count_completed"
for (step in 1..count) {
if (currentPackage() != PDD) { reason = "left_pdd"; break }
val screen = driver.capture()
if (screen.packageName != PDD) { reason = "left_pdd"; break }
if (screen.activityName.isNullOrBlank() || unsafe(screen)) {
reason = "unsafe_page"
return false
}
// Recheck after capturing: never send Back to another foreground app.
if (currentPackage() != PDD) { reason = "left_pdd"; break }
actions++
if (!driver.back()) { reason = "back_failed"; break }
pause(500)
}
// Retain risk scenes even if the last Back lands on one.
if (currentPackage() == PDD && unsafe(driver.capture())) {
reason = "unsafe_page"
return false
}
if (!openAgent()) { reason = "agent_launch_failed"; return false }
pause(500)
if (currentPackage() != AGENT) { reason = "agent_not_confirmed"; return false }
return true
} catch (_: Exception) {
reason = "navigation_exception"
return false
}
}
private fun unsafe(screen: UiSnapshot): Boolean =
PddPageClassifier.classify(screen.packageName, screen.activityName,
screen.nodes.filter { it.visible }.map { it.label }) != null ||
screen.nodes.any { it.visible && listOf("订单详情", "订单待支付", "确认付款", "立即支付", "编辑收货地址", "新增收货地址", "地址管理").any(it.label::contains) }
companion object {
private const val PDD = "com.xunmeng.pinduoduo"
private const val AGENT = "cn.ilapage.goauto.agent"
}
}
@@ -18,7 +18,7 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.automation.PddCollectionHomeNavigator
import cn.ilapage.goauto.agent.automation.PddCollectionReturnNavigator
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import java.io.File
@@ -662,19 +662,20 @@ class AgentForegroundService : Service() {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
val returnCount = settingsStore.continuousReturnCount()
val outcome = executeTaskWhileAwake(api, initialTask, token, continuous)
if (!continuous) outcome else {
cancelIdleReturn("连续采集保留 PDD 页面")
ContinuousCollectionPolicy.finish(continuous, outcome) {
val driver = GoAutoAccessibilityService.instance ?: return@finish false
val navigator = PddCollectionHomeNavigator(driver)
val home = navigator.returnHome(RuleParser.parse(initialTask.ruleSnapshot))
val navigator = PddCollectionReturnNavigator(driver, driver::openAgentPreservingTab, driver::currentPackage)
val returned = navigator.returnToAgent(returnCount)
runCatching {
val hash = java.security.MessageDigest.getInstance("SHA-256")
.digest(initialTask.ruleSnapshot.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousHome=$home;reason=${navigator.reason};actions=${navigator.actions}")
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousReturn=$returned;configuredBacks=$returnCount;reason=${navigator.reason};actions=${navigator.actions}")
}
home
returned
}.also { result ->
if (result.successful) runCatching { stateStore.update("ONLINE", result.message.orEmpty(), tokenStored = true) }
}
@@ -124,6 +124,19 @@ class AgentSettingsStore internal constructor(private val preferences: SharedPre
fun continuousCollection(): Boolean = runCatching { preferences.getBoolean(CONTINUOUS_COLLECTION, false) }.getOrDefault(false)
fun continuousReturnCount(): Int = runCatching {
preferences.getInt(CONTINUOUS_RETURN_COUNT, 3).takeIf { it in 1..5 } ?: 3
}.getOrDefault(3)
fun saveContinuousReturnCount(count: Int) {
require(count in 1..5) { "返回次数须为 1~5 次" }
val previous = continuousReturnCount()
if (!preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, count).commit()) {
preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, previous).commit()
error("返回次数未保存,请重试")
}
}
fun saveContinuousCollection(enabled: Boolean) {
val previous = continuousCollection()
if (!preferences.edit().putBoolean(CONTINUOUS_COLLECTION, enabled).commit()) {
@@ -190,6 +203,7 @@ class AgentSettingsStore internal constructor(private val preferences: SharedPre
const val SERVER_URL = "server_url"
const val DEVICE_NAME = "device_name"
const val CONTINUOUS_COLLECTION = "continuous_collection"
const val CONTINUOUS_RETURN_COUNT = "continuous_collection_return_count"
const val HISTORY_DAYS = "history_days"
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds"
@@ -5,17 +5,17 @@ internal object ContinuousCollectionPolicy {
fun applies(enabled: Boolean, source: String, attemptNumber: Int, replacementOrigin: String): Boolean =
enabled && source == "agent_current_page" && attemptNumber == 1 && replacementOrigin.isBlank()
fun returnHome(enabledForTask: Boolean, submitted: Boolean, status: String?): Boolean =
fun shouldReturn(enabledForTask: Boolean, submitted: Boolean, status: String?): Boolean =
enabledForTask && submitted && status == "completed"
fun finish(enabledForTask: Boolean, outcome: TaskExecutionSummary, navigate: () -> Boolean): TaskExecutionSummary {
if (!enabledForTask || !outcome.successful) return outcome
if (!returnHome(enabledForTask, outcome.successful, outcome.resultStatus)) {
if (!shouldReturn(enabledForTask, outcome.successful, outcome.resultStatus)) {
return outcome.copy(message = "采集部分成功,已保留现场,请查看采集记录。")
}
val home = runCatching(navigate).getOrDefault(false)
return outcome.copy(message = if (home) "采集已完成,已返回拼多多首页,可继续拍照搜商品。"
else "采集已完成,请手动返回拼多多首页。")
val returned = runCatching(navigate).getOrDefault(false)
return outcome.copy(message = if (returned) "采集已完成,返回操作已结束,已切回 Agent。"
else "采集已完成,自动返回未完成,请手动处理当前页面并打开 Agent。")
}
}
@@ -66,6 +66,7 @@ class AgentSettingsFragment : Fragment() {
private lateinit var collectionIntervalEndInput: TextInputEditText
private lateinit var collectionIntervalFeedback: TextView
private lateinit var continuousCollectionSwitch: SwitchMaterial
private lateinit var continuousReturnCountButton: MaterialButton
private lateinit var continuousCollectionFeedback: TextView
private var refreshingContinuousCollection = false
private lateinit var historyDaysLayout: TextInputLayout
@@ -289,10 +290,35 @@ class AgentSettingsFragment : Fragment() {
onFailure = { "未保存:${it.message ?: "请重试"}" },
)
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
refreshContinuousReturnCount()
}
}
}
addView(continuousCollectionSwitch, fullWidth())
continuousReturnCountButton = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
minHeight = context.dp(48)
setTextColor(context.getColor(R.color.agent_text))
setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle("返回次数")
.setSingleChoiceItems(arrayOf("1 次", "2 次", "3 次", "4 次", "5 次"), settingsStore.continuousReturnCount() - 1) { dialog, which ->
val result = runCatching {
val current = stateStore.read()
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
check(settingsStore.continuousCollection()) { "请先开启连续采集" }
settingsStore.saveContinuousReturnCount(which + 1)
}
continuousCollectionFeedback.text = result.fold(onSuccess = { "" }, onFailure = { "未保存:${it.message ?: "请重试"}" })
continuousCollectionFeedback.visibility = if (result.isSuccess) View.GONE else View.VISIBLE
refreshContinuousReturnCount()
dialog.dismiss()
}
.setNegativeButton("取消", null)
.show()
}
}
addView(continuousReturnCountButton, fullWidth(8))
refreshContinuousReturnCount()
continuousCollectionFeedback = context.label("", 14f, context.getColor(R.color.agent_text_muted)).apply {
visibility = View.GONE
accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
@@ -696,12 +722,20 @@ class AgentSettingsFragment : Fragment() {
return normalizedUrl
}
private fun refreshContinuousReturnCount() {
val current = stateStore.read()
continuousReturnCountButton.text = "返回次数:${settingsStore.continuousReturnCount()} 次"
continuousReturnCountButton.visibility = if (settingsStore.continuousCollection()) View.VISIBLE else View.GONE
continuousReturnCountButton.isEnabled = current.code != "BUSY" && current.currentTaskId == null
}
private fun refreshDiagnostics() {
if (!isAdded || view == null) return
val context = requireContext()
val state = stateStore.read()
val busy = state.code == "BUSY" || state.currentTaskId != null
continuousCollectionSwitch.isEnabled = !busy
refreshContinuousReturnCount()
if (busy) continuousCollectionFeedback.text = "任务结束后可修改"
else if (continuousCollectionFeedback.text == "任务结束后可修改") continuousCollectionFeedback.text = ""
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
@@ -27,22 +27,47 @@ class ContinuousCollectionSettingsTest {
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
}
@Test fun `return count defaults three and persists valid bounds`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
assertEquals(3, store.continuousReturnCount())
for (count in 1..5) {
store.saveContinuousReturnCount(count)
assertEquals(count, AgentSettingsStore(disk.preferences).continuousReturnCount())
}
for (count in listOf(0, 6)) {
assertTrue(runCatching { store.saveContinuousReturnCount(count) }.isFailure)
assertEquals(5, store.continuousReturnCount())
}
}
@Test fun `return count failed write restores previous and invalid stored value defaults`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
store.saveContinuousReturnCount(2)
disk.failNextCommit = true
assertTrue(runCatching { store.saveContinuousReturnCount(4) }.isFailure)
assertEquals(2, AgentSettingsStore(disk.preferences).continuousReturnCount())
disk.preferences.edit().putInt("continuous_collection_return_count", 99).commit()
assertEquals(3, store.continuousReturnCount())
}
private class MemoryPreferences {
private val values = mutableMapOf<String, Boolean>()
private val values = mutableMapOf<String, Any>()
var failNextCommit = false
val preferences = Proxy.newProxyInstance(SharedPreferences::class.java.classLoader, arrayOf(SharedPreferences::class.java)) { _, method, args ->
when (method.name) {
"getBoolean" -> values[args!![0] as String] ?: args[1]
"getBoolean", "getInt" -> values[args!![0] as String] ?: args[1]
"edit" -> editor()
else -> error("Unexpected preference access ${method.name}")
}
} as SharedPreferences
private fun editor(): SharedPreferences.Editor {
val pending = mutableMapOf<String, Boolean>()
val pending = mutableMapOf<String, Any>()
return Proxy.newProxyInstance(SharedPreferences.Editor::class.java.classLoader, arrayOf(SharedPreferences.Editor::class.java)) { proxy, method, args ->
when (method.name) {
"putBoolean" -> { pending[args!![0] as String] = args[1] as Boolean; proxy }
"putBoolean", "putInt" -> { pending[args!![0] as String] = args[1]; proxy }
"commit" -> {
values.putAll(pending)
val success = !failNextCommit
@@ -7,197 +7,157 @@ import org.junit.Assert.*
import org.junit.Test
class ContinuousCollectionTest {
private val rule = CollectionRule(2, emptyList())
private val complete = TaskExecutionSummary(true, resultStatus = "completed")
@Test fun `home navigation failure cannot rewrite successful collection result`() {
val complete = TaskExecutionSummary(true, resultStatus = "completed")
val result = ContinuousCollectionPolicy.finish(true, complete) { error("navigation failed") }
assertTrue(result.successful)
assertEquals("completed", result.resultStatus)
assertNull(result.code)
assertTrue(result.message!!.contains("手动返回"))
val failed = TaskExecutionSummary(false, "UPLOAD_TIMEOUT", "未确认提交")
assertEquals(failed, ContinuousCollectionPolicy.finish(true, failed) { error("must not run") })
assertEquals(complete, ContinuousCollectionPolicy.finish(false, complete) { error("must not run") })
var calls = 0
val partial = ContinuousCollectionPolicy.finish(true, complete.copy(resultStatus = "completed_partial")) { calls++; true }
assertEquals(0, calls)
assertTrue(partial.message!!.contains("部分成功"))
assertTrue(ContinuousCollectionPolicy.finish(true, complete) { calls++; true }.message!!.contains("已返回"))
assertEquals(1, calls)
}
@Test fun `only new ordinary current page requests opt in`() {
@Test fun `only new ordinary temporary collection participates`() {
assertTrue(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(false, "agent_current_page", 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(true, "admin", 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(true, "purchase", 1, ""))
for (source in listOf("admin", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, source, 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 2, ""))
assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, "collection"))
assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, "purchase"))
for (origin in listOf("collection", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, origin))
}
@Test fun `partial failure or unacknowledged result never navigates`() {
for (status in listOf(null, "failed", "completed_partial", "running")) {
assertFalse(ContinuousCollectionPolicy.returnHome(true, true, status))
@Test fun `only acknowledged complete result can navigate`() {
for (status in listOf("completed_partial", "failed", "running", null)) assertFalse(ContinuousCollectionPolicy.shouldReturn(true, true, status))
assertFalse(ContinuousCollectionPolicy.shouldReturn(true, false, "completed"))
assertFalse(ContinuousCollectionPolicy.shouldReturn(false, true, "completed"))
assertTrue(ContinuousCollectionPolicy.shouldReturn(true, true, "completed"))
}
@Test fun `failure partial and disabled never invoke navigation`() {
val failed = TaskExecutionSummary(false, "failed", "failure")
assertEquals(failed, ContinuousCollectionPolicy.finish(true, failed) { error("unexpected") })
assertEquals(complete, ContinuousCollectionPolicy.finish(false, complete) { error("unexpected") })
val partial = ContinuousCollectionPolicy.finish(true, complete.copy(resultStatus = "completed_partial")) { error("unexpected") }
assertTrue(partial.message!!.contains("保留现场"))
}
@Test fun `navigation failure never rewrites collection success`() {
for (navigate in listOf<() -> Boolean>({ false }, { error("failure") })) {
val result = ContinuousCollectionPolicy.finish(true, complete, navigate)
assertTrue(result.successful)
assertEquals(complete.code, result.code)
assertEquals("completed", result.resultStatus)
assertTrue(result.message!!.contains("手动"))
}
assertFalse(ContinuousCollectionPolicy.returnHome(true, false, "completed"))
assertFalse(ContinuousCollectionPolicy.returnHome(false, true, "completed"))
assertTrue(ContinuousCollectionPolicy.returnHome(true, true, "completed"))
val success = ContinuousCollectionPolicy.finish(true, complete) { true }
assertTrue(success.message!!.contains("已切回 Agent"))
assertFalse(success.message!!.contains("首页"))
}
@Test fun `observed home uses selected child not unselected clickable parent`() {
assertTrue(PddCollectionHomeNavigator.isHome(home()))
assertFalse(PddCollectionHomeNavigator.isHome(home(selected = false)))
assertFalse(PddCollectionHomeNavigator.isHome(home(camera = false)))
assertFalse(PddCollectionHomeNavigator.isHome(home().copy(activityName = null)))
assertFalse(PddCollectionHomeNavigator.isHome(home().copy(packageName = "other")))
@Test fun `configured counts send exact backs even when page is unchanged`() {
for (count in 1..5) {
val d = Driver()
val waits = mutableListOf<Long>()
val navigator = d.navigator { waits += it }
assertTrue(navigator.returnToAgent(count))
assertEquals(count, d.backs)
assertEquals(count, navigator.actions)
assertEquals(1, d.opens)
assertEquals(List(count + 1) { 500L }, waits)
assertEquals("count_completed", navigator.reason)
}
}
@Test fun `fake home text in content or duplicate home is not enough`() {
assertFalse(PddCollectionHomeNavigator.isHome(page(node("fake", "首页", selected = true))))
val h = home()
assertFalse(PddCollectionHomeNavigator.isHome(h.copy(nodes = h.nodes.filter { it.label != "聊天" })))
assertFalse(PddCollectionHomeNavigator.isHome(h.copy(nodes = h.nodes + h.nodes.first { it.path == "home/text" }.copy(path = "duplicate"))))
@Test fun `invalid counts do not navigate`() {
for (count in listOf(-1, 0, 6, Int.MAX_VALUE)) {
val d = Driver()
assertFalse(d.navigator().returnToAgent(count))
assertEquals(0, d.backs + d.opens)
}
}
@Test fun `already home requires no actions`() {
val d = Driver(listOf(home()))
assertTrue(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(0, d.backs + d.clicks)
@Test fun `leaving pdd stops remaining backs and returns to agent`() {
val d = Driver().apply { leaveAfter = 1 }
val navigator = d.navigator()
assertTrue(navigator.returnToAgent(3))
assertEquals(1, d.backs)
assertEquals(1, d.opens)
assertEquals("left_pdd", navigator.reason)
}
@Test fun `each back is followed by capture and stops at verified home`() {
val d = Driver(listOf(backPage("detail"), backPage("search"), home()))
assertTrue(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(listOf("capture", "back", "capture", "back", "capture"), d.calls)
}
@Test fun `unselected bottom home can be clicked once and must verify content`() {
val d = Driver(listOf(home(selected = false), home()))
assertTrue(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(1, d.clicks)
@Test fun `already outside pdd sends no back`() {
val d = Driver().apply { pkg = "launcher" }
assertTrue(d.navigator().returnToAgent(3))
assertEquals(0, d.backs)
val noEffect = Driver(listOf(home(selected = false)))
assertFalse(PddCollectionHomeNavigator(noEffect, {}).returnHome(rule))
assertEquals(1, noEffect.clicks)
assertEquals(1, d.opens)
}
@Test fun `unchanged screen stops after one back`() {
val d = Driver(listOf(backPage("unchanged")))
assertFalse(PddCollectionHomeNavigator(d, {}).returnHome(rule))
@Test fun `back failure stops without retry but can return to agent`() {
val d = Driver().apply { backSuccess = false }
val navigator = d.navigator()
assertTrue(navigator.returnToAgent(3))
assertEquals(1, d.backs)
assertEquals("back_failed", navigator.reason)
}
@Test fun `unknown page foreground change and risky pages stop without actions`() {
for (p in listOf(page(node("unknown", "未知页面")), backPage("other").copy(packageName = "other"),
page(node("risk", "订单详情"), node("back", "返回")), page(node("risk", "安全验证"), node("back", "返回")))) {
val d = Driver(listOf(p))
assertFalse(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(0, d.backs + d.clicks)
}
val d = Driver(listOf(backPage("detail"), home().copy(packageName = "other")))
assertFalse(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(1, d.backs)
}
@Test fun `changing screens have a hard action bound and exceptions are nonfatal`() {
val d = Driver((0..9).map { backPage("$it") })
assertFalse(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(PddCollectionHomeNavigator.MAX_ACTIONS, d.backs)
val failure = Driver(listOf(backPage("detail"))).apply { throwOnBack = true }
assertFalse(PddCollectionHomeNavigator(failure, {}).returnHome(rule))
}
@Test fun `photo search layer takes priority over underlying home tabs`() {
val search = photoSearch(home())
assertFalse(PddCollectionHomeNavigator.isHome(search))
val d = Driver(listOf(search, home(selected = false), home()))
val navigator = PddCollectionHomeNavigator(d, {})
assertTrue(navigator.returnHome(rule))
assertEquals(listOf("capture", "back", "capture", "click", "capture"), d.calls)
assertEquals("home_confirmed", navigator.reason)
assertEquals(2, navigator.actions)
}
@Test fun `search without labelled back returns but ordinary camera entry does not`() {
val d = Driver(listOf(photoSearch(page()), home()))
assertTrue(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(1, d.backs)
assertFalse(PddCollectionHomeNavigator.isPhotoSearchLayer(home()))
}
@Test fun `unchanged search waits bounded time without repeated back`() {
val d = Driver(listOf(photoSearch(page())))
val waits = mutableListOf<Long>()
val navigator = PddCollectionHomeNavigator(d, { waits += it })
assertFalse(navigator.returnHome(rule))
assertEquals("search_back_unchanged", navigator.reason)
assertEquals(1, d.backs)
assertEquals(listOf(500L, 500L, 500L), waits)
}
@Test fun `delayed search exit is read again without another navigation action`() {
val search = photoSearch(page())
val d = Driver(listOf(search, search, home())).apply { advanceOnCapture = true }
val navigator = PddCollectionHomeNavigator(d, {})
assertTrue(navigator.returnHome(rule))
assertEquals(1, d.backs)
assertEquals(listOf("capture", "back", "capture", "capture"), d.calls)
}
@Test fun `search overlay never bypasses risky page or foreground guard`() {
for (screen in listOf(photoSearch(page(node("risk", "订单详情"))),
photoSearch(page()).copy(packageName = "other"))) {
val d = Driver(listOf(screen))
val navigator = PddCollectionHomeNavigator(d, {})
assertFalse(navigator.returnHome(rule))
@Test fun `risk pages preserve scene including after last back`() {
for (after in listOf(0, 1)) {
val d = Driver().apply { riskAfter = after }
val navigator = d.navigator()
assertFalse(navigator.returnToAgent(1))
assertEquals(after, d.backs)
assertEquals(0, d.opens)
assertEquals("unsafe_page", navigator.reason)
assertEquals(0, d.backs + d.clicks)
}
val d = Driver(listOf(photoSearch(page()), home().copy(packageName = "other")))
assertFalse(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(1, d.backs)
}
private fun photoSearch(base: UiSnapshot) = base.copy(nodes = base.nodes +
node("search-layer", "拍照搜索").copy(className = "android.widget.FrameLayout", bounds = NodeBounds(0, 0, 1080, 2354)))
@Test fun `switching foreground during capture never sends back to other app`() {
val d = Driver().apply { changeDuringCapture = true }
assertTrue(d.navigator().returnToAgent(3))
assertEquals(0, d.backs)
}
private fun node(path: String, label: String, y: Int = 30, selected: Boolean = false, clickable: Boolean = false, parent: String? = null) =
SnapshotNode(path, parent, label, null, null, "android.widget.TextView", NodeBounds(20, y, 120, y + 40),
clickable, false, selected, false, true, true)
@Test fun `agent launch and foreground confirmation failures are explicit`() {
for (launch in listOf(false, true)) {
val d = Driver().apply { launchSuccess = launch; confirmAgent = false }
val navigator = d.navigator()
assertFalse(navigator.returnToAgent(1))
assertEquals(if (launch) "agent_not_confirmed" else "agent_launch_failed", navigator.reason)
}
}
private fun page(vararg nodes: SnapshotNode) = UiSnapshot("com.xunmeng.pinduoduo", "PddActivity", listOf(
node("root", "").copy(bounds = NodeBounds(0, 0, 1080, 2354)), *nodes))
@Test fun `missing activity or capture exception does not send back`() {
for (throws in listOf(false, true)) {
val d = Driver().apply { missingActivity = true; throwCapture = throws }
assertFalse(d.navigator().returnToAgent(3))
assertEquals(0, d.backs + d.opens)
}
}
private fun backPage(identity: String) = page(node("back", "返回"), node(identity, identity, 300))
private fun home(selected: Boolean = true, camera: Boolean = true): UiSnapshot = page(
node("home", "首页", 2159, clickable = true).copy(bounds = NodeBounds(0, 2159, 216, 2306)),
node("home/text", "首页", 2259, selected = selected, parent = "home"),
node("chat", "聊天", 2259), node("profile", "个人中心", 2259),
node("camera", if (camera) "拍照搜索" else "", 161, clickable = true),
)
private class Driver(val screens: List<UiSnapshot>) : PddCollectorDriver {
var index = 0
private class Driver : PddCollectorDriver {
var pkg = "com.xunmeng.pinduoduo"
var backs = 0
var clicks = 0
var throwOnBack = false
var advanceOnCapture = false
val calls = mutableListOf<String>()
var opens = 0
var leaveAfter = Int.MAX_VALUE
var riskAfter = Int.MAX_VALUE
var backSuccess = true
var launchSuccess = true
var confirmAgent = true
var missingActivity = false
var throwCapture = false
var changeDuringCapture = false
fun navigator(pause: (Long) -> Unit = {}) = PddCollectionReturnNavigator(this, {
opens++
if (launchSuccess && confirmAgent) pkg = "cn.ilapage.goauto.agent"
launchSuccess
}, { pkg }, pause)
override fun capture(): UiSnapshot {
calls += "capture"
return screens[index.coerceAtMost(screens.lastIndex)].also { if (advanceOnCapture) index++ }
if (throwCapture) error("capture failed")
val snapshot = UiSnapshot(pkg, if (missingActivity) null else "PddActivity", if (backs >= riskAfter) listOf(
SnapshotNode("risk", null, "订单详情", null, null, "android.widget.TextView", NodeBounds(0, 0, 100, 100),
false, false, false, false, true, true),
) else emptyList())
if (changeDuringCapture) pkg = "launcher"
return snapshot
}
override fun back(): Boolean {
calls += "back"; backs++
if (throwOnBack) error("simulated")
if (!advanceOnCapture) index++
return true
backs++
if (backs >= leaveAfter) pkg = "launcher"
return backSuccess
}
override fun clickFresh(target: SnapshotNode): FreshActionResult { calls += "click"; clicks++; index++; return FreshActionResult.SUCCESS }
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = error("unexpected swipe")
override fun pullDownGoodsPage() = error("unexpected pull down")
override fun clickFresh(target: SnapshotNode) = error("must not click page controls")
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = error("must not swipe")
override fun pullDownGoodsPage() = error("must not pull down")
}
}