Improve Android connection and preview UI testing
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "org.qimiaoscreen.controller"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 3
|
||||
versionName = "0.2.0"
|
||||
versionCode = 4
|
||||
versionName = "0.2.1"
|
||||
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ class AppLifecycleTest {
|
||||
click(ui.findObject(By.text("连接")))
|
||||
click(ui.findObject(By.text("⋮")))
|
||||
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
|
||||
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 0.2.0")), 5_000))
|
||||
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 " + BuildConfig.VERSION_NAME)), 5_000))
|
||||
click(ui.findObject(By.text("关闭")))
|
||||
click(ui.findObject(By.text("⋮")))
|
||||
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
|
||||
|
||||
+118
-8
@@ -3,13 +3,16 @@ package org.qimiaoscreen.controller
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.StaleObjectException
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Read semantic bounds; inject all input through the authorized root helper. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -23,19 +26,126 @@ class LayoutTest {
|
||||
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
|
||||
Thread.sleep(250)
|
||||
}
|
||||
fun visible(selector: BySelector, timeout: Long, scroll: Boolean = false,
|
||||
accept: (UiObject2) -> Boolean = { true }): UiObject2? {
|
||||
val deadline = android.os.SystemClock.elapsedRealtime() + timeout
|
||||
var attempts = 0
|
||||
while (android.os.SystemClock.elapsedRealtime() < deadline) {
|
||||
try {
|
||||
ui.findObjects(selector).firstOrNull { !it.visibleBounds.isEmpty && accept(it) }?.let { return it }
|
||||
val list = if (scroll) ui.findObject(By.res("connection_list")) else null
|
||||
if (list != null && !list.visibleBounds.isEmpty) {
|
||||
check(list.applicationPackage == "org.qimiaoscreen.controller")
|
||||
val b = list.visibleBounds
|
||||
check(b.left >= 0 && b.top >= 0 && b.right <= ui.displayWidth && b.bottom <= ui.displayHeight)
|
||||
val upper = b.top + b.height() / 5
|
||||
val lower = b.bottom - b.height() / 5
|
||||
val down = attempts++ % 10 < 5
|
||||
input("swipe ${b.centerX()} ${if (down) lower else upper} ${b.centerX()} ${if (down) upper else lower} 350")
|
||||
}
|
||||
} catch (_: StaleObjectException) {
|
||||
// Polling or scrolling replaced the semantic tree. Discard it
|
||||
// and resolve fresh nodes; never reuse the old coordinates.
|
||||
}
|
||||
Thread.sleep(250)
|
||||
}
|
||||
return null
|
||||
}
|
||||
fun stableTap(resolve: () -> UiObject2?) {
|
||||
val deadline = android.os.SystemClock.elapsedRealtime() + 5_000
|
||||
var previous: android.graphics.Rect? = null
|
||||
while (android.os.SystemClock.elapsedRealtime() < deadline) {
|
||||
try {
|
||||
val node = resolve()
|
||||
if (node != null) {
|
||||
check(node.applicationPackage == "org.qimiaoscreen.controller")
|
||||
val b = node.visibleBounds
|
||||
if (!b.isEmpty && b.left >= 0 && b.top >= 0 && b.right <= ui.displayWidth && b.bottom <= ui.displayHeight) {
|
||||
if (b == previous) { input("tap ${b.centerX()} ${b.centerY()}"); return }
|
||||
previous = android.graphics.Rect(b)
|
||||
} else previous = null
|
||||
} else previous = null
|
||||
} catch (_: StaleObjectException) { previous = null }
|
||||
Thread.sleep(400)
|
||||
}
|
||||
error("Root target did not obtain stable visible bounds")
|
||||
}
|
||||
fun click(label: String) {
|
||||
check(ui.wait(Until.hasObject(By.text(label)), 15_000)) { "Layout control unavailable: $label" }
|
||||
val node = checkNotNull(ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty }
|
||||
.maxByOrNull { it.visibleCenter.y }) { "Visible layout target unavailable: $label" }
|
||||
check(node.applicationPackage == "org.qimiaoscreen.controller")
|
||||
val bounds = node.visibleBounds
|
||||
assertTrue(bounds.left >= 0 && bounds.top >= 0 && bounds.right <= ui.displayWidth && bounds.bottom <= ui.displayHeight)
|
||||
val point = node.visibleCenter
|
||||
input("tap ${point.x} ${point.y}")
|
||||
check(visible(By.text(label), 15_000, label == "扫描设备") != null) { "Layout control unavailable: $label" }
|
||||
stableTap { ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty }.maxByOrNull { it.visibleCenter.y } }
|
||||
}
|
||||
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
|
||||
assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
|
||||
for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label)
|
||||
click("⋮"); click("关于"); click("关闭")
|
||||
fun tagged(tag: String): UiObject2 = checkNotNull(visible(By.res(tag), 35_000, tag == "connected_scan_disconnect")) {
|
||||
"Required App semantic tag unavailable: $tag"
|
||||
}
|
||||
fun tap(node: UiObject2) {
|
||||
val tag = checkNotNull(node.resourceName)
|
||||
stableTap { ui.findObjects(By.res(tag)).firstOrNull { !it.visibleBounds.isEmpty && it.isEnabled } }
|
||||
}
|
||||
fun stage(name: String) = instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "Layout stage: $name\n") })
|
||||
fun connectScanned() {
|
||||
click("扫描设备")
|
||||
val name = InstrumentationRegistry.getArguments().getString("device_name")!!
|
||||
checkNotNull(visible(By.res("scan_connect"), 20_000, scroll = true) {
|
||||
it.isEnabled && it.parent?.findObject(By.text(name)) != null
|
||||
}) { "Visible target scan button unavailable" }
|
||||
stableTap { ui.findObjects(By.res("scan_connect")).firstOrNull {
|
||||
!it.visibleBounds.isEmpty && it.isEnabled && it.parent?.findObject(By.text(name)) != null
|
||||
} }
|
||||
stage("connect-request-observed=" + ui.hasObject(By.textContains("连接中")))
|
||||
val deadline = android.os.SystemClock.elapsedRealtime() + 35_000
|
||||
while (!ui.hasObject(By.res("connected_scan_disconnect")) && android.os.SystemClock.elapsedRealtime() < deadline) {
|
||||
val failureText = ui.findObject(By.res("controller_error"))?.text
|
||||
if (failureText != null) {
|
||||
val category = listOf("超时", "蓝牙连接已中断", "蓝牙连接失败", "身份", "不兼容", "会话").firstOrNull { failureText.contains(it) } ?: "未分类"
|
||||
error("Connection failed after root input: $category")
|
||||
}
|
||||
Thread.sleep(250)
|
||||
}
|
||||
tagged("connected_scan_disconnect")
|
||||
}
|
||||
try {
|
||||
stage("initial-connect")
|
||||
if (visible(By.res("connected_scan_disconnect"), 35_000, scroll = true) == null) connectScanned()
|
||||
val summary = tagged("connection_summary")
|
||||
assertTrue(summary.text.startsWith("当前连接设备:"))
|
||||
assertTrue(summary.text.contains(" · "))
|
||||
assertTrue(summary.visibleBounds.right <= ui.findObject(By.text("⋮")).visibleBounds.left)
|
||||
stage("scan-while-connected"); click("扫描设备")
|
||||
tagged("connected_scan_disconnect")
|
||||
Thread.sleep(13_000)
|
||||
assertTrue(ui.hasObject(By.res("connected_scan_disconnect")))
|
||||
for (page in listOf("设备状态", "显示内容")) {
|
||||
stage("frame-" + if (page == "设备状态") "status" else "content")
|
||||
click(page)
|
||||
tagged("frame_image")
|
||||
Thread.sleep(500)
|
||||
val card = tagged("frame_card").visibleBounds
|
||||
val title = tagged("frame_title").visibleBounds
|
||||
val image = tagged("frame_image").visibleBounds
|
||||
val refresh = tagged("frame_refresh").visibleBounds
|
||||
assertTrue("Title must share card center", abs(title.centerX() - card.centerX()) <= 3)
|
||||
assertTrue("Image must share card center", abs(image.centerX() - card.centerX()) <= 3)
|
||||
assertTrue("Title and refresh must not overlap", title.right <= refresh.left)
|
||||
assertTrue(refresh.centerX() > card.centerX() && refresh.right <= card.right)
|
||||
assertTrue(image.left >= card.left && image.right <= card.right)
|
||||
tap(tagged("frame_refresh"))
|
||||
}
|
||||
click("连接")
|
||||
stage("disconnect-row")
|
||||
tap(tagged("connected_scan_disconnect"))
|
||||
assertTrue(ui.wait(Until.gone(By.text("断开连接")), 10_000))
|
||||
assertTrue(ui.wait(Until.gone(By.res("connected_scan_disconnect")), 10_000))
|
||||
stage("manual-reconnect"); connectScanned()
|
||||
stage("final-disconnect")
|
||||
tap(tagged("connected_scan_disconnect"))
|
||||
assertTrue(ui.wait(Until.gone(By.text("断开连接")), 10_000))
|
||||
} finally {
|
||||
click("连接")
|
||||
visible(By.res("connected_scan_disconnect"), 5_000, scroll = true)?.let { tap(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user