diff --git a/apps/android-viewer/app/src/test/java/com/verify/remote/CursorMarkerTest.kt b/apps/android-viewer/app/src/test/java/com/verify/remote/CursorMarkerTest.kt new file mode 100644 index 0000000..b7888d9 --- /dev/null +++ b/apps/android-viewer/app/src/test/java/com/verify/remote/CursorMarkerTest.kt @@ -0,0 +1,115 @@ +package com.verify.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 4b/V-D1:光标标记映射的纯逻辑(协议 v5)。 + * + * 这些断言对应验收判据,不是「跑通就算」: + * - V-D1「标记位置=映射值」→ [center_maps_to_view_center] / [full_extent_linear_map] + * - 「(0,0) 是合法位置」→ [origin_maps_not_treated_as_none](协议用 Option 的全部理由) + * - 跨信任边界自证 → 越界与负值一律 null(Linux 后端未实现/被篡改时的防线) + */ +class CursorMarkerTest { + + // ---- 核心等比映射 ---- + + /** + * 横屏典型几何:桌面 1920x1080,视图 1080x607(满宽 letterbox)。 + * 桌面中心应映射到视图中心。这是 V-D1 的锚点用例——真机 proof 就是把 + * SetCursorPos 打到桌面中心,再在截图里断言标记簇心在视图中心 ±6px。 + */ + @Test + fun center_maps_to_view_center() { + val p = CursorMarker.mapToView( + desktopW = 1920, desktopH = 1080, + cursor = 960 to 540, + viewW = 1080, viewH = 607, + ) + assertNotNull(p) + assertEquals(540f, p!!.x, 1f) + assertEquals(303.5f, p.y, 1f) + } + + /** + * 满量程线性映射:桌面右下角 (1919,1079) → 视图右下角附近, + * 且不越过视图边界(映射后必须仍在画面内可画)。 + */ + @Test + fun full_extent_linear_map() { + val p = CursorMarker.mapToView(1920, 1080, 1919 to 1079, 1080, 607) + assertNotNull(p) + assertEquals(1080f * 1919 / 1920f, p!!.x, 0.5f) + assertEquals(607f * 1079 / 1080f, p.y, 0.5f) + assertTrue("映射 x 不得越过视图右边界", p.x < 1080f) + assertTrue("映射 y 不得越过视图下边界", p.y < 607f) + } + + /** + * (0,0) 必须映射成视图左上角,**而不是 null**。 + * 这条锁住最危险的错误:实现把「左上角」和「没有光标」混为一谈 + * (比如用 (0,0) 当缺省值再判 ==0 返回 null)。协议专门用 Option 区分二者, + * 这层区分在 Kotlin 侧不能被抹平。 + */ + @Test + fun origin_maps_not_treated_as_none() { + val p = CursorMarker.mapToView(1920, 1080, 0 to 0, 1080, 607) + assertNotNull("左上角是合法光标位置,不得当成无光标丢弃") + assertEquals(0f, p!!.x, 0.001f) + assertEquals(0f, p.y, 0.001f) + } + + /** 1:1 场景(视图尺寸恰等于桌面):坐标原样透传。 */ + @Test + fun identity_when_sizes_match() { + val p = CursorMarker.mapToView(1280, 720, 640 to 360, 1280, 720) + assertEquals(640f, p!!.x, 0.001f) + assertEquals(360f, p.y, 0.001f) + } + + // ---- null 分支:一律「不画」而非画到别处 ---- + + /** cursor=null(隐藏/离屏/后端未实现)→ null。 */ + @Test + fun null_cursor_returns_null() { + assertNull(CursorMarker.mapToView(1920, 1080, null, 1080, 607)) + } + + /** + * 任一尺寸未就绪(0 或负)→ null,绝不按 0 除得到 NaN/Infinity。 + * 覆盖四种:视图未测量(width 0,letterbox onMeasure 前)、 + * 桌面尺寸还没从 AuthOk 取到、以及负值这种异常。 + */ + @Test + fun not_ready_or_degenerate_dimensions_return_null() { + assertNull(CursorMarker.mapToView(0, 1080, 10 to 10, 1080, 607)) // 桌面宽未就绪 + assertNull(CursorMarker.mapToView(1920, 0, 10 to 10, 1080, 607)) // 桌面高未就绪 + assertNull(CursorMarker.mapToView(1920, 1080, 10 to 10, 0, 607)) // 视图未测量 + assertNull(CursorMarker.mapToView(1920, 1080, 10 to 10, 1080, 0)) // 视图高 0 + assertNull(CursorMarker.mapToView(-5, 1080, 10 to 10, 1080, 607)) // 异常负尺寸 + } + + /** + * 越界坐标 → null(不画)。这是跨信任边界的自证:被控端 `fold_cursor` 本应折叠掉 + * 越界值,但协议把对端字节按不可信输入处理——若折叠漏网或被篡改,这里兜住, + * 否则标记会画到画面外(甚至映射出一个「看似合理」的错位置)。 + */ + @Test + fun out_of_desktop_bounds_returns_null() { + assertNull(CursorMarker.mapToView(1920, 1080, 1920 to 500, 1080, 607)) // 右边界外一列 + assertNull(CursorMarker.mapToView(1920, 1080, 500 to 1080, 1080, 607)) // 下边界外一行 + assertNull(CursorMarker.mapToView(1920, 1080, -1 to 500, 1080, 607)) // 负 X(副屏穿透) + assertNull(CursorMarker.mapToView(1920, 1080, 500 to -1, 1080, 607)) // 负 Y + assertNull(CursorMarker.mapToView(1920, 1080, 3000 to 500, 1080, 607)) // 远超桌面 + } + + /** 恰好界内 (desktopW-1, desktopH-1) 必须画(与上一条配对,钉死 >= 还是 > 的边界)。 */ + @Test + fun exactly_in_bounds_draws() { + assertNotNull(CursorMarker.mapToView(1920, 1080, 1919 to 1079, 1080, 607)) + } +} diff --git a/apps/android-viewer/app/src/test/java/com/verify/remote/RustBridgeTest.kt b/apps/android-viewer/app/src/test/java/com/verify/remote/RustBridgeTest.kt new file mode 100644 index 0000000..c754292 --- /dev/null +++ b/apps/android-viewer/app/src/test/java/com/verify/remote/RustBridgeTest.kt @@ -0,0 +1,106 @@ +package com.verify.remote + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 4b:[FrameCodec] 帧头拆包的**跨端字节契约**测试(golden bytes)。 + * + * 为什么值这个钱:android-ffi/src/lib.rs 按 buf[0]/[1..9]/[9..17]/[17..21]/[21..25]/ + * [25]/[26..28]/[28..30] 写字节,Kotlin 按同样的偏移读。**任何一侧挪一位**(尤其 v5 + * 从 25→30 这次),Rust 编译过、Kotlin 也编译过、真机能连上——只是标记画到错误位置 + * 或永远不显示,静默失败(全局规则:核过 Rust 一端不等于核过 Kotlin 另一端)。 + * + * 期望值用 goldenHeader 按 Rust 侧写入布局**手算**生成(`to_le_bytes()` 的逐字节结果)。 + * Rust 侧若改偏移或改字节序,这里的对拍就红。这是唯一能在 JVM 里锁住 JNI 二进制布局的 + * 手段(真机跑不了单测)。拆包逻辑之所以住在顶层 [FrameCodec] 而不是 RustBridge object, + * 正因为后者带 loadLibrary 的 init,测试里不可达。 + */ +class RustBridgeTest { + + /** + * 「IDR、seq=0x0102030405060708、ts=0x0011223344556677、桌面 1920x1080、 + * 光标 has=x/y」的 30 字节头,逐字节 = android-ffi 的写入布局。 + */ + private fun goldenHeader(hasCursor: Int, x: Int, y: Int): ByteArray { + val bytes = listOf( + 0x01, // [0] keyframe = true + // [1..9] seq = 0x0102030405060708 LE + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, + // [9..17] ts = 0x0011223344556677 LE + 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, + // [17..21] width = 1920 (0x0780) LE + 0x80, 0x07, 0x00, 0x00, + // [21..25] height = 1080 (0x0438) LE + 0x38, 0x04, 0x00, 0x00, + // [25] has_cursor + hasCursor, + // [26..28] cursor.x i16 LE + x and 0xFF, (x shr 8) and 0xFF, + // [28..30] cursor.y i16 LE + y and 0xFF, (y shr 8) and 0xFF, + ) + return bytes.map { it.toByte() }.toByteArray() + } + + /** 光标 Some((960,540)):逐字段核对,锁死 [25]/[26..28]/[28..30] 三个新偏移。 */ + @Test + fun parses_v5_header_with_cursor() { + val payload = byteArrayOf(0xAA.toInt().toByte(), 0xBB.toInt().toByte(), 0xCC.toInt().toByte()) + val raw = goldenHeader(hasCursor = 1, x = 960, y = 540) + payload + val f = FrameCodec.parse(raw)!! + assertEquals(true, f.keyframe) + assertEquals(0x0102030405060708L, f.seq) + assertEquals(0x0011223344556677L, f.tsMs) + assertEquals(1920, f.width) + assertEquals(1080, f.height) + assertEquals(960 to 540, f.cursor) + assertArrayEquals(payload, f.payload) + } + + /** + * has_cursor=0 时**即便坐标字节是全 1 位模式(0xFFFF = -1)也必须读成 null**。 + * 这条锁住 `if (hasCursor)` 的判断:若误判成「非零即有」,(-1,-1) 会穿透成一个 + * 看似合法的错位置——正是协议用独立 has 字节而非「0 表示无」的原因。 + */ + @Test + fun has_cursor_zero_means_none_regardless_of_coord_bytes() { + // 故意把 x/y 字节填成 -1 的位模式(0xFFFF) + val raw = goldenHeader(hasCursor = 0, x = 0xFFFF, y = 0xFFFF) + byteArrayOf(0x00) + val f = FrameCodec.parse(raw)!! + assertNull("has=0 时坐标字节无意义,必须 null", f.cursor) + } + + /** 负光标坐标(有符号 i16)必须正确读回——agent 折叠后不该出现,但拆包层要忠实。 */ + @Test + fun reads_signed_cursor_coords() { + val raw = goldenHeader(hasCursor = 1, x = (-1) and 0xFFFF, y = (-32768) and 0xFFFF) + byteArrayOf(0) + val f = FrameCodec.parse(raw)!! + assertEquals(-1 to -32768, f.cursor) + } + + /** 光标坐标取 i16 两端边界 (32767, -32768) 往返,证明 readI16 符号扩展正确。 */ + @Test + fun cursor_i16_extremes() { + val raw = goldenHeader(hasCursor = 1, x = 32767, y = -32768) + byteArrayOf(0) + val f = FrameCodec.parse(raw)!! + assertEquals(32767 to -32768, f.cursor) + } + + /** 短于 30 字节的缓冲(旧 25 字节布局或截断帧)必须返回 null,不能切片越界崩溃。 */ + @Test + fun rejects_truncated_buffer() { + assertNull(FrameCodec.parse(ByteArray(29))) + } + + /** 恰好 30 字节(空载荷)是合法的最小帧;载荷长度为 0 而非 null。 */ + @Test + fun accepts_exact_header_with_empty_payload() { + val raw = goldenHeader(hasCursor = 1, x = 100, y = 200) + val f = FrameCodec.parse(raw)!! + assertEquals(100 to 200, f.cursor) + assertEquals(0, f.payload.size) + } +}