diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fccd663..b1c9bad 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 = 84 - versionName = "0.9.71" + versionCode = 85 + versionName = "0.9.72" 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 deleted file mode 100644 index b7fcd5b..0000000 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionHomeNavigator.kt +++ /dev/null @@ -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 } - } - } -} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionReturnNavigator.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionReturnNavigator.kt new file mode 100644 index 0000000..41073f0 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddCollectionReturnNavigator.kt @@ -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" + } +} 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 e18405e..02ed1b2 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,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) } } 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 adb38b2..bbb36a0 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 @@ -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" 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 index 1f4d77a..23c7b11 100644 --- 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 @@ -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。") } } 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 07fea8a..17272e4 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 @@ -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 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 index 49a98f8..cf4c00e 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionSettingsTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionSettingsTest.kt @@ -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() + 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] + "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() + 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 } + "putBoolean", "putInt" -> { pending[args!![0] as String] = args[1]; proxy } "commit" -> { values.putAll(pending) val success = !failNextCommit 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 index a6dcf21..5f95700 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ContinuousCollectionTest.kt @@ -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() + 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() - 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) : 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() + 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") } }