feat(android): add continuous temporary collection home return #250

This commit is contained in:
QiuSW
2026-09-09 14:18:53 +08:00
parent 659bbc6e1a
commit 074e5aab29
8 changed files with 392 additions and 20 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 80
versionName = "0.9.67"
versionCode = 81
versionName = "0.9.68"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,73 @@
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,
) {
fun returnHome(rule: CollectionRule): Boolean = runCatching {
var screen = driver.capture()
repeat(MAX_ACTIONS) {
if (!safe(screen)) return false
if (isHome(screen)) return true
val before = signature(screen)
val home = homeTab(screen)
if (home != null) {
if (driver.clickFresh(home) != FreshActionResult.SUCCESS) return false
} else {
val parsed = rule.collector?.let { PddScreenParser.parse(screen, it, "", rule.pageEvidence) }
val knownProduct = parsed != null && parsed.pageEvidenceMatched &&
(parsed.specPanelOpen || parsed.specEntry != null)
if (!knownProduct && topBack(screen) == null) return false
if (!driver.back()) return false
}
// Read after each individual action; never send a sequence of blind Back events.
pause(500)
screen = driver.capture()
if (!safe(screen)) return false
if (isHome(screen)) return true
if (signature(screen) == before) return false
}
false
}.getOrDefault(false)
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 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)) 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 }
}
}
}
@@ -18,6 +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.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import java.io.File
@@ -356,11 +357,16 @@ class AgentForegroundService : Service() {
}
runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
val outcome = executeTask(api, task, credentials.token)
val continuous = ContinuousCollectionPolicy.applies(
settingsStore.continuousCollection(), task.source, task.attemptNumber, task.replacementOriginType,
)
val outcome = executeTask(api, task, credentials.token, continuous)
publishCurrentPageResult(
task.taskId,
if (outcome.successful) {
if (task.replacementOriginType.isNotBlank()) {
if (continuous) {
outcome.message ?: "采集已结束,请查看采集记录。"
} else if (task.replacementOriginType.isNotBlank()) {
"已替换,正在匹配规格"
} else {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
@@ -651,11 +657,27 @@ class AgentForegroundService : Service() {
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
continuous: Boolean = false,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
executeTaskWhileAwake(api, initialTask, token)
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 home = PddCollectionHomeNavigator(driver).returnHome(RuleParser.parse(initialTask.ruleSnapshot))
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")
}
home
}.also { result ->
if (result.successful) runCatching { stateStore.update("ONLINE", result.message.orEmpty(), tokenStored = true) }
}
}
} finally {
releaseTaskWakeLock()
}
@@ -665,6 +687,7 @@ class AgentForegroundService : Service() {
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
continuous: Boolean = false,
): TaskExecutionSummary {
var started = initialTask.status == "running"
var resultSafelySubmitted = false
@@ -771,25 +794,25 @@ class AgentForegroundService : Service() {
}
}
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
beginPostCollectionCooldowns()
TaskExecutionSummary(successful = true)
beginPostCollectionCooldowns(returnToAgent = !continuous)
TaskExecutionSummary(successful = true, resultStatus = result.status)
} catch (error: TaskFailure) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (resultSafelySubmitted) beginPostCollectionCooldowns()
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
}
TaskExecutionSummary(false, error.code, error.message)
} catch (error: AgentApiException) {
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
resultSafelySubmitted = true
beginPostCollectionCooldowns()
beginPostCollectionCooldowns(returnToAgent = !continuous)
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
TaskExecutionSummary(false, error.code, error.message)
} catch (error: Exception) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
if (resultSafelySubmitted) beginPostCollectionCooldowns()
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
}
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
} finally {
@@ -848,8 +871,8 @@ class AgentForegroundService : Service() {
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
}
private fun beginPostCollectionCooldowns() {
beginIdleReturnCooldown()
private fun beginPostCollectionCooldowns(returnToAgent: Boolean = true) {
if (returnToAgent) beginIdleReturnCooldown() else cancelIdleReturn("连续采集不自动返回 Agent")
val ticket = CollectionCooldownPolicy.arm(
System.currentTimeMillis(),
settingsStore.collectionIntervalRange(),
@@ -1168,9 +1191,3 @@ class AgentForegroundService : Service() {
}
private class TaskFailure(val code: String, message: String) : Exception(message)
private data class TaskExecutionSummary(
val successful: Boolean,
val code: String? = null,
val message: String? = null,
)
@@ -1,6 +1,7 @@
package cn.ilapage.goauto.agent.service
import android.content.Context
import android.content.SharedPreferences
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
import kotlin.random.Random
@@ -113,14 +114,25 @@ internal object TaskDispatchPolicy {
}
}
class AgentSettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
class AgentSettingsStore internal constructor(private val preferences: SharedPreferences) {
constructor(context: Context) : this(context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE))
fun serverUrl(): String = preferences.getString(SERVER_URL, null)
?: BuildConfig.DEFAULT_SERVER_URL.takeIf { it.isNotBlank() }.orEmpty()
fun deviceName(): String = preferences.getString(DEVICE_NAME, "").orEmpty()
fun continuousCollection(): Boolean = runCatching { preferences.getBoolean(CONTINUOUS_COLLECTION, false) }.getOrDefault(false)
fun saveContinuousCollection(enabled: Boolean) {
val previous = continuousCollection()
if (!preferences.edit().putBoolean(CONTINUOUS_COLLECTION, enabled).commit()) {
// SharedPreferences also updates memory on failed disk commits; restore the displayed value.
preferences.edit().putBoolean(CONTINUOUS_COLLECTION, previous).commit()
error("连续采集设置未保存,请重试")
}
}
fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7))
fun collectionIntervalRange(): CollectionIntervalRange {
@@ -177,6 +189,7 @@ class AgentSettingsStore(context: Context) {
const val PREFERENCES = "goauto_agent_settings"
const val SERVER_URL = "server_url"
const val DEVICE_NAME = "device_name"
const val CONTINUOUS_COLLECTION = "continuous_collection"
const val HISTORY_DAYS = "history_days"
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds"
@@ -0,0 +1,27 @@
package cn.ilapage.goauto.agent.service
/** Only the foreground, newly requested temporary collection opts in. */
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 =
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)) {
return outcome.copy(message = "采集部分成功,已保留现场,请查看采集记录。")
}
val home = runCatching(navigate).getOrDefault(false)
return outcome.copy(message = if (home) "采集已完成,已返回拼多多首页,可继续拍照搜商品。"
else "采集已完成,请手动返回拼多多首页。")
}
}
internal data class TaskExecutionSummary(
val successful: Boolean,
val code: String? = null,
val message: String? = null,
val resultStatus: String? = null,
)
@@ -34,6 +34,7 @@ import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
import cn.ilapage.goauto.agent.update.UpdateCheckResult
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import java.util.concurrent.Executors
@@ -64,6 +65,9 @@ class AgentSettingsFragment : Fragment() {
private lateinit var collectionIntervalEndLayout: TextInputLayout
private lateinit var collectionIntervalEndInput: TextInputEditText
private lateinit var collectionIntervalFeedback: TextView
private lateinit var continuousCollectionSwitch: MaterialSwitch
private lateinit var continuousCollectionFeedback: TextView
private var refreshingContinuousCollection = false
private lateinit var historyDaysLayout: TextInputLayout
private lateinit var historyDaysInput: TextInputEditText
private lateinit var historySyncButton: MaterialButton
@@ -257,6 +261,40 @@ class AgentSettingsFragment : Fragment() {
collectionIntervalFeedback.setPadding(0, context.dp(8), 0, 0)
addView(collectionIntervalFeedback)
}))
addView(context.card(context.cardColumn().apply {
continuousCollectionSwitch = MaterialSwitch(context).apply {
text = "连续采集"
textSize = 18f
setTextColor(context.getColor(R.color.agent_text))
minHeight = context.dp(48)
isChecked = settingsStore.continuousCollection()
setOnCheckedChangeListener { _, enabled ->
if (!refreshingContinuousCollection) {
val current = stateStore.read()
val result = runCatching {
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
settingsStore.saveContinuousCollection(enabled)
}
refreshingContinuousCollection = true
isChecked = settingsStore.continuousCollection()
refreshingContinuousCollection = false
continuousCollectionFeedback.text = result.fold(
onSuccess = { if (enabled) "已开启,仅对之后新建的临时采集生效。" else "已关闭,恢复普通采集流程。" },
onFailure = { "未保存:${it.message ?: "请重试"}" },
)
}
}
}
addView(continuousCollectionSwitch, fullWidth())
addView(context.label(
"仅限手动临时采集。完整采集并提交后返回拼多多首页,继续拍照搜商品。\n失败或部分成功保留现场;不会自动创建下一条任务。",
14f, context.getColor(R.color.agent_text_muted),
), fullWidth(8))
continuousCollectionFeedback = context.label("", 14f, context.getColor(R.color.agent_text_muted)).apply {
accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
}
addView(continuousCollectionFeedback, fullWidth(8))
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("任务记录", 18f, context.getColor(R.color.agent_text), true))
val syncRow = LinearLayout(context).apply {
@@ -659,6 +697,9 @@ class AgentSettingsFragment : Fragment() {
val context = requireContext()
val state = stateStore.read()
val busy = state.code == "BUSY" || state.currentTaskId != null
continuousCollectionSwitch.isEnabled = !busy
if (busy) continuousCollectionFeedback.text = "任务结束后可修改"
else if (continuousCollectionFeedback.text == "任务结束后可修改") continuousCollectionFeedback.text = ""
if (busy && updateDownloading) updateCancelled.set(true)
val editable = SettingsAvailabilityResolver.editable(state.code, state.currentTaskId, testing)
serverInput.isEnabled = editable
@@ -0,0 +1,57 @@
package cn.ilapage.goauto.agent
import android.content.SharedPreferences
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import java.lang.reflect.Proxy
import org.junit.Assert.*
import org.junit.Test
class ContinuousCollectionSettingsTest {
@Test fun `default off persists and survives store recreation`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
assertFalse(store.continuousCollection())
store.saveContinuousCollection(true)
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
store.saveContinuousCollection(false)
assertFalse(AgentSettingsStore(disk.preferences).continuousCollection())
}
@Test fun `failed disk commit restores previous in memory preference`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
store.saveContinuousCollection(true)
disk.failNextCommit = true
assertTrue(runCatching { store.saveContinuousCollection(false) }.isFailure)
assertTrue(store.continuousCollection())
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
}
private class MemoryPreferences {
private val values = mutableMapOf<String, Boolean>()
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]
"edit" -> editor()
else -> error("Unexpected preference access ${method.name}")
}
} as SharedPreferences
private fun editor(): SharedPreferences.Editor {
val pending = mutableMapOf<String, Boolean>()
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 }
"commit" -> {
values.putAll(pending)
val success = !failNextCommit
failNextCommit = false
success
}
else -> error("Unexpected preference edit ${method.name}")
}
} as SharedPreferences.Editor
}
}
}
@@ -0,0 +1,144 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.service.ContinuousCollectionPolicy
import cn.ilapage.goauto.agent.service.TaskExecutionSummary
import org.junit.Assert.*
import org.junit.Test
class ContinuousCollectionTest {
private val rule = CollectionRule(2, emptyList())
@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`() {
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, ""))
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"))
}
@Test fun `partial failure or unacknowledged result never navigates`() {
for (status in listOf(null, "failed", "completed_partial", "running")) {
assertFalse(ContinuousCollectionPolicy.returnHome(true, true, status))
}
assertFalse(ContinuousCollectionPolicy.returnHome(true, false, "completed"))
assertFalse(ContinuousCollectionPolicy.returnHome(false, true, "completed"))
assertTrue(ContinuousCollectionPolicy.returnHome(true, true, "completed"))
}
@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 `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 `already home requires no actions`() {
val d = Driver(listOf(home()))
assertTrue(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(0, d.backs + d.clicks)
}
@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)
assertEquals(0, d.backs)
val noEffect = Driver(listOf(home(selected = false)))
assertFalse(PddCollectionHomeNavigator(noEffect, {}).returnHome(rule))
assertEquals(1, noEffect.clicks)
}
@Test fun `unchanged screen stops after one back`() {
val d = Driver(listOf(backPage("unchanged")))
assertFalse(PddCollectionHomeNavigator(d, {}).returnHome(rule))
assertEquals(1, d.backs)
}
@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))
}
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)
private fun page(vararg nodes: SnapshotNode) = UiSnapshot("com.xunmeng.pinduoduo", "PddActivity", listOf(
node("root", "").copy(bounds = NodeBounds(0, 0, 1080, 2354)), *nodes))
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
var backs = 0
var clicks = 0
var throwOnBack = false
val calls = mutableListOf<String>()
override fun capture(): UiSnapshot { calls += "capture"; return screens[index.coerceAtMost(screens.lastIndex)] }
override fun back(): Boolean {
calls += "back"; backs++
if (throwOnBack) error("simulated")
index++; return true
}
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")
}
}