feat(android): configure continuous collection back count #250
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
-101
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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
-5
@@ -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
|
||||
|
||||
+29
-4
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user