feat(android): add continuous temporary collection home return #250
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+73
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-16
@@ -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"
|
||||
|
||||
+27
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user