Improve controller behavior and update project requirements

This commit is contained in:
2026-09-30 15:42:36 +08:00
parent 55374356ed
commit 071f06d365
52 changed files with 2205 additions and 335 deletions
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="QMS UI tests" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false">
<activity android:name=".FixtureActivity" android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,79 @@
package org.qimiaoscreen.uitest
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import android.os.Bundle
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.json.*
import org.qimiaoscreen.core.*
import org.qimiaoscreen.ui.QimiaoApp
import androidx.compose.ui.graphics.ImageBitmap
/** Only in the self-instrumenting test APK. No AndroidBle, network or private data. */
class FixtureActivity : ComponentActivity() {
val platform = FixturePlatform()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val controller = Controller(platform, scope)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { QimiaoApp(controller, controller::scan, { ImageBitmap(64, 64) }) }
}
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
}
class FixturePlatform : MobilePlatform {
override val model = "模拟测试手机"
private val saved = mutableMapOf<String, String>()
val calls = mutableListOf<String>()
var scanError = false
var emptyLibrary = true
override fun load(key: String) = saved[key]
override fun save(key: String, value: String) { saved[key] = value }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) {
if (scanError) onError("模拟扫描失败") else onDevice(DiscoveredDevice("fixture", "QMS-00000000", -40))
}
override fun stopScan() {}
override suspend fun connect(handle: String): DeviceLink {
check(handle == "fixture")
return FixtureLink(this)
}
override fun crypto() = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
}
private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
override val mtu = 247
private val reassembler = Reassembler()
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
private var sequence = 0L
override suspend fun send(fragment: ByteArray) {
val (kind, payload) = reassembler.accept(fragment) ?: return
val response = if (kind == 1) byteArrayOf(2) else {
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
val method = request.getValue("method").jsonPrimitive.content
platform.calls += method
val result = when (method) {
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"]}"""
"session.ping" -> """{"alive":true}"""
"status.get" -> """{"state":{"animation_playback":{"active":false}}}"""
"frame.get" -> """{"frame_revision":"fixture","data_base64":"fixture-image"}"""
"settings.get" -> """{"revision":"fixture","values":{"brightness":40,"orientation":0,"prompt_delay_seconds":5}}"""
"wifi.get" -> """{"revision":"fixture","saved":null,"active":{"connected":false}}"""
"wifi.scan" -> """{"networks":[]}"""
"library.summary" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","item_count":0}""" else """{"library_revision":"fixture-full","item_count":1}"""
"library.list" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","items":[],"next_cursor":null}""" else """{"library_revision":"fixture-full","items":[{"id":"fixture","type":"template","name":"测试模板","revision":"fixture-1","playable":true}],"next_cursor":null}"""
"content.default.get" -> """{"name":"测试默认"}"""
"library.preview" -> """{"data_base64":"fixture-preview"}"""
"library.thumbnail" -> """{"data_base64":"fixture-image"}"""
else -> error("Unexpected fixture RPC: $method")
}
buildJsonObject { put("id", request.getValue("id")); put("ok", true); put("result", Protocol.json.parseToJsonElement(result)) }.toString().encodeToByteArray()
}
Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) }
}
override suspend fun receive() = messages.receive()
override fun close() { messages.close() }
}
@@ -0,0 +1,93 @@
package org.qimiaoscreen.uitest
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.*
class IsolatedUiTest {
@get:Rule val ui = createAndroidComposeRule<FixtureActivity>()
private fun click(text: String) { ui.onNodeWithText(text).performClick() }
private fun connect() {
click("扫描设备")
ui.waitUntil(5_000) { ui.activity.controller.state.value.devices.isNotEmpty() }
ui.onNodeWithTag("scan_connect").performScrollTo().performClick()
ui.waitUntil(5_000) { ui.activity.controller.state.value.status != null }
}
@Test fun connection() {
ui.onNodeWithText("暂无已保存设备,扫描后选择即可连接").assertExists()
ui.runOnIdle { ui.activity.platform.scanError = true }
click("扫描设备")
ui.onNodeWithText("模拟扫描失败").assertExists()
click("知道了")
ui.onNodeWithTag("controller_error").assertDoesNotExist()
ui.runOnIdle { ui.activity.platform.scanError = false }
connect()
ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick()
ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null }
}
@Test fun status() {
click("设备状态")
ui.onNodeWithText("请先在连接页连接设备").assertExists()
click("连接"); connect(); click("设备状态")
ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null }
ui.onNodeWithTag("frame_card").assertIsDisplayed()
ui.onNodeWithTag("frame_refresh").performClick()
ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy }
assertTrue(ui.activity.platform.calls.contains("frame.get"))
}
@Test fun content() {
connect(); click("显示内容")
ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy }
ui.onNodeWithText("可播放内容").performScrollTo().assertIsDisplayed()
ui.onNodeWithText("播放").assertDoesNotExist()
ui.runOnIdle { ui.activity.platform.emptyLibrary = false; ui.activity.controller.refresh() }
ui.waitUntil(5_000) { ui.activity.controller.state.value.library.isNotEmpty() }
ui.onNodeWithText("测试模板").performScrollTo().assertIsDisplayed()
ui.onNodeWithText("播放").assertIsEnabled()
ui.waitUntil(7_000) { ui.activity.controller.state.value.previews.isNotEmpty() }
ui.waitUntil(7_000) { ui.activity.controller.state.value.thumbnails.isNotEmpty() }
assertTrue(ui.activity.platform.calls.indexOf("library.preview") < ui.activity.platform.calls.indexOf("library.thumbnail"))
assertFalse(ui.activity.platform.calls.contains("content.play"))
}
@Test fun settings() {
connect(); click("设备设置")
ui.waitUntil(5_000) { ui.activity.controller.state.value.settings != null }
ui.onNodeWithText("WiFi设置 展开").performScrollTo().performClick()
ui.onNodeWithText("保存WiFi设置").performScrollTo().assertIsNotEnabled()
ui.onNodeWithText("WiFi名称").performScrollTo().performTextInput("fixture-network")
ui.onNodeWithText("保存WiFi设置").performScrollTo().performClick()
ui.onNodeWithText("立即生效WiFi设置").assertExists()
click("取消")
assertFalse(ui.activity.platform.calls.contains("wifi.set"))
}
@Test fun navigation() {
for (page in listOf("设备状态", "显示内容", "设备设置", "连接")) click(page)
click("⋮"); click("关于")
ui.onNodeWithText("关于奇妙小屏幕").assertExists(); click("关闭")
click("⋮"); click("软件设置")
ui.onNodeWithText("当前手机昵称").performTextReplacement("模拟昵称")
click("保存")
ui.runOnIdle { assertEquals("模拟昵称", ui.activity.controller.state.value.clientName) }
click("⋮"); click("软件设置")
ui.onNodeWithText("内容缓存:0 KiB").assertExists()
ui.onNodeWithTag("cache_clear_open").performClick()
ui.onNodeWithTag("cache_clear_confirm").performClick()
assertTrue(ui.activity.platform.calls.isEmpty())
}
@Test fun layout() {
connect()
for (page in listOf("设备状态", "显示内容")) {
click(page)
ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null }
val card = ui.onNodeWithTag("frame_card").fetchSemanticsNode().boundsInRoot
val image = ui.onNodeWithTag("frame_image").fetchSemanticsNode().boundsInRoot
val title = ui.onNodeWithTag("frame_title").fetchSemanticsNode().boundsInRoot
val refresh = ui.onNodeWithTag("frame_refresh").fetchSemanticsNode().boundsInRoot
assertTrue(kotlin.math.abs(image.center.x - card.center.x) <= 3)
assertTrue(kotlin.math.abs(title.center.x - card.center.x) <= 3)
assertTrue(title.right <= refresh.left)
}
}
}