test(android): JUnit 测试基础设施 + 19 个纯逻辑单测(3a/U7,Q3=A 批准)

仅 testImplementation junit:4.13.2(测试作用域,不进 APK,runtime deps 仍空);
versionName→0.1-phase3a。覆盖:WinScancode 映射表(右 Shift 非扩展/UNKNOWN 落 null/
F 键值钉死待 U6)、ConnectErrorText(每类中文非空、编号契约、未知不猜)、
planBackspaces((0,0)/(after>0)/截断 64)。含 mutation 反向验证:断言写反即红。
This commit is contained in:
郑博文 2026-10-31 13:30:07 +08:00
parent 718b66617f
commit bb3cf6b09b
4 changed files with 221 additions and 2 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.verify.remote" applicationId = "com.verify.remote"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 2
versionName = "0.1-phase1" versionName = "0.1-phase3a"
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归 // 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
ndk { ndk {
abiFilters += listOf("arm64-v8a") abiFilters += listOf("arm64-v8a")
@ -25,6 +25,15 @@ android {
} }
} }
// 3a/U7:安卓测试基础设施(Q3=A,用户 2026-10-31 批准)。
// **仅测试作用域**:junit 不进 APK,runtime dependencies 保持为空。
testOptions {
unitTests {
// android.jar 桩方法抛 "not mocked"——本工程的单测刻意只测纯逻辑
// (不构造 Android 对象),此开关只兜常量引用类的类加载副作用。
isReturnDefaultValues = true
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@ -53,4 +62,6 @@ android {
dependencies { dependencies {
// 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。 // 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。
// AndroidX 在阶段 2 做正式 UI 时再引入。 // AndroidX 在阶段 2 做正式 UI 时再引入。
// 3a/U7(Q3=A 批准):仅 JUnit 测试作用域,不进 APK。
testImplementation("junit:junit:4.13.2")
} }

View File

@ -0,0 +1,80 @@
package com.verify.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 3a/U7:连接错误编号→文案映射。
* 编号契约的另一端是 Rust(`viewer_core::ConnectErrorKind::code`,
* 由 `cargo test -p viewer-core ffi_error_codes_are_stable_and_unique` 锁死),
* 这里锁 Kotlin 侧的完整覆盖与文案质量——两端一致才算契约成立。
*/
class ConnectErrorTextTest {
@Test
fun every_known_kind_has_actionable_chinese_text() {
val kinds = listOf(
ConnectErrorText.KIND_RESOLVE,
ConnectErrorText.KIND_TIMEOUT,
ConnectErrorText.KIND_REFUSED,
ConnectErrorText.KIND_AUTH,
ConnectErrorText.KIND_VERSION,
ConnectErrorText.KIND_OTHER,
ConnectErrorText.KIND_FFI_SELF,
)
for (k in kinds) {
val s = ConnectErrorText.summary(k)
assertTrue("kind=$k 必须有文案", !s.isNullOrEmpty())
// 项目纪律:用户可见输出中文(文案里至少含一个 CJK 字符)
assertTrue("kind=$k 文案必须中文", s!!.any { c -> c.code in 0x4E00..0x9FFF })
}
}
@Test
fun codes_match_ffi_contract() {
// 期望值 = android-ffi / viewer-core 的 code() 表(改动必须三处同步)
assertEquals(1, ConnectErrorText.KIND_RESOLVE)
assertEquals(2, ConnectErrorText.KIND_TIMEOUT)
assertEquals(3, ConnectErrorText.KIND_REFUSED)
assertEquals(4, ConnectErrorText.KIND_AUTH)
assertEquals(5, ConnectErrorText.KIND_VERSION)
assertEquals(9, ConnectErrorText.KIND_OTHER)
assertEquals(-1, ConnectErrorText.KIND_FFI_SELF)
assertEquals(0, ConnectErrorText.KIND_NONE)
}
@Test
fun texts_are_distinct_per_failure_mode() {
// 五种可区分失败不能给同一句文案(V-A2 的映射前提)
val texts = listOf(
ConnectErrorText.KIND_RESOLVE,
ConnectErrorText.KIND_TIMEOUT,
ConnectErrorText.KIND_REFUSED,
ConnectErrorText.KIND_AUTH,
ConnectErrorText.KIND_VERSION,
).map { ConnectErrorText.summary(it)!! }
assertEquals(texts.size, texts.distinct().size)
for (t in texts) {
assertNotEquals("连接失败", t) // 可分类的不得落兜底文案
}
}
@Test
fun unknown_kind_falls_back_without_claiming_a_cause() {
// 编号漂移时不猜语义:兜底文案 + 带未知编号(R-8:宁可少说不可说错)
assertNull(ConnectErrorText.summary(42))
val line = ConnectErrorText.firstLine(42)
assertTrue("实际: $line", line.contains("42"))
assertTrue("实际: $line", line.startsWith("连接失败"))
}
@Test
fun firstLine_uses_summary_for_known_kinds() {
for (k in listOf(1, 2, 3, 4, 5, 9, -1)) {
assertEquals(ConnectErrorText.summary(k), ConnectErrorText.firstLine(k))
}
}
}

View File

@ -0,0 +1,46 @@
package com.verify.remote
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* 3a/U7:退格决策纯函数。原逻辑在 `RemoteInputConnection.deleteBackspaces`
* (构造链依赖 android.jar 桩,JVM 里不可测),3a 提取为 [planBackspaces] 后
* 首次可单测。期望值对照 2b 真机行为(语义不变,见 RemoteInputConnection 注释)。
*/
class PlanBackspacesTest {
@Test
fun zero_zero_is_noop() {
assertEquals(BackspaceAction.Noop, planBackspaces(0, 0))
}
@Test
fun after_positive_is_unsupported_and_reports_both_counts() {
// U3:forward-delete 形态本地无远端真值,必须显式 Unsupported(可见后果),
// 不得静默当退格执行——before 值要随形态带出去供 debug 通道显示
assertEquals(BackspaceAction.Unsupported(2, 1), planBackspaces(2, 1))
assertEquals(BackspaceAction.Unsupported(0, 3), planBackspaces(0, 3))
}
@Test
fun before_only_maps_to_backspace_count() {
// IME 退格最常见形态(R-9 实测)
assertEquals(BackspaceAction.Backspaces(3), planBackspaces(3, 0))
assertEquals(BackspaceAction.Backspaces(1), planBackspaces(1, 0))
}
@Test
fun count_is_capped_at_limit() {
// 防 IME 异常大值刷爆消息队列;截断语义必须发生在计划层(执行层无感知上限)
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(64, 0))
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(999, 0))
assertEquals(BackspaceAction.Backspaces(MAX_BACKSPACE), planBackspaces(1_000_000, 0))
}
@Test
fun MAX_BACKSPACE_constant_is_pinned() {
// 与 RemoteInputConnection 的 companion 原值一致(提取时不得改数)
assertEquals(64, MAX_BACKSPACE)
}
}

View File

@ -0,0 +1,82 @@
package com.verify.remote
import android.view.KeyEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* 3a/U7 首批 JVM 单测:扫描码映射表(此前只能真机点验)。
*
* `KeyEvent.KEYCODE_*` 是 Java 编译期常量(static final int),引用会被内联进
* 测试字节码——JVM 单测不触发 android.jar 桩类加载,所以这里不需要 Robolectric。
* 期望值来自项目内部权威源 `viewer/src/keymap.rs`(同 R-1:不信记忆,对照项目已有用法)。
*/
class WinScancodeTest {
@Test
fun enter_is_make_0x1C_not_extended() {
assertEquals(WinKey(0x1C, false), WinScancode.of(KeyEvent.KEYCODE_ENTER))
}
@Test
fun backspace_is_0x0E() {
assertEquals(WinKey(0x0E, false), WinScancode.of(KeyEvent.KEYCODE_DEL))
}
@Test
fun forward_delete_is_0x53_extended() {
assertEquals(WinKey(0x53, true), WinScancode.of(KeyEvent.KEYCODE_FORWARD_DEL))
}
@Test
fun arrows_are_extended() {
assertEquals(WinKey(0x48, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_UP))
assertEquals(WinKey(0x50, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_DOWN))
assertEquals(WinKey(0x4B, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_LEFT))
assertEquals(WinKey(0x4D, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_RIGHT))
}
@Test
fun right_shift_is_NOT_extended_with_its_own_scancode() {
// 微软 Keyboard Input Overview:右 Shift 不是扩展键,它有独立扫描码 0x36
// (2b 的 D4 偏差修正,回归锁死)
assertEquals(WinKey(0x36, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_RIGHT))
assertEquals(WinKey(0x2A, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_LEFT))
}
@Test
fun right_ctrl_and_alt_are_extended_shared_scancodes() {
assertEquals(WinKey(0x1D, true), WinScancode.of(KeyEvent.KEYCODE_CTRL_RIGHT))
assertEquals(WinKey(0x1D, false), WinScancode.of(KeyEvent.KEYCODE_CTRL_LEFT))
assertEquals(WinKey(0x38, true), WinScancode.of(KeyEvent.KEYCODE_ALT_RIGHT))
}
@Test
fun letters_and_digits_match_keymap_rs() {
// 抽查与 keymap.rs 交集的字母/数字(Set-1 make code)
assertEquals(WinKey(0x1E, false), WinScancode.of(KeyEvent.KEYCODE_A))
assertEquals(WinKey(0x2C, false), WinScancode.of(KeyEvent.KEYCODE_Z))
assertEquals(WinKey(0x02, false), WinScancode.of(KeyEvent.KEYCODE_1))
assertEquals(WinKey(0x0B, false), WinScancode.of(KeyEvent.KEYCODE_0))
assertEquals(WinKey(0x39, false), WinScancode.of(KeyEvent.KEYCODE_SPACE))
}
@Test
fun unmapped_and_unknown_keycodes_return_null() {
// KEYCODE_UNKNOWN=0:软键盘可打印字符的形态,必须落 null 让调用方走 Text 通道,
// 不能兜底成任意扫描码(2b D13 的根因之一)
assertNull(WinScancode.of(KeyEvent.KEYCODE_UNKNOWN))
assertNull(WinScancode.of(0))
assertNull(WinScancode.of(-1))
assertNull(WinScancode.of(9999))
}
@Test
fun f_keys_pinned_until_u6_physical_keyboard_verification() {
// N5/U6:F1-F10 来自 Set-1 常识与 keymap.rs 交集,未在 Windows 逐一对账;
// 这里锁「当前值不变」,防止无声漂移——真机接物理键盘验证后(U6)才允许改期望值
assertEquals(WinKey(0x3B, false), WinScancode.of(KeyEvent.KEYCODE_F1))
assertEquals(WinKey(0x44, false), WinScancode.of(KeyEvent.KEYCODE_F10))
}
}