diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 616ce4d..02a19d0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt new file mode 100644 index 0000000..5cab79f --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt @@ -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 } + } + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index e23c503..6aa76b3 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -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, -) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt index 047361c..adb38b2 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/ContinuousCollectionPolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/ContinuousCollectionPolicy.kt new file mode 100644 index 0000000..1f4d77a --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/ContinuousCollectionPolicy.kt @@ -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, +) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt index 812c9db..8f5742b 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt @@ -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 diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionSettingsTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionSettingsTest.kt new file mode 100644 index 0000000..49a98f8 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionSettingsTest.kt @@ -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() + 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() + 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 + } + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt new file mode 100644 index 0000000..be54fde --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt @@ -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) : PddCollectorDriver { + var index = 0 + var backs = 0 + var clicks = 0 + var throwOnBack = false + val calls = mutableListOf() + 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") + } +}