refactor(android-ffi): 抽 pack_frame_header 并加写端字面量对拍单测

步骤 4 的跨端契约闭环:把 30 字节头构造从 nativeNextFrame 内联提取成纯函数,
写端只有一条路径(消除内联/函数两处并存 = R-5 同源漂移)。

新增 host 可跑的 3 条单测(#[cfg(test)],进 --workspace --exclude linux-agent 口径):
- header_bytes_match_the_declared_layout:逐字节手抄常数,与 Kotlin 侧
  RustBridgeTest.goldenHeader 是同一组——两侧各握一份手抄,改任一偏移另一侧必红
  (只锁读端有盲区:Kotlin 字面量若本身抄错 Rust 布局会跟着一起错还全绿)
- none_cursor_writes_zero_flag_and_zero_coords:None 分支 has=0 且坐标字节不残留脏值
- negative_coords_keep_bit_pattern:i16 负坐标位模式忠实
android-ffi 在 host(gnu) cargo test 可编译可跑,实测 3 passed。
This commit is contained in:
郑博文 2026-11-02 11:04:51 +08:00
parent 8eed1e20e1
commit 4379b01d41

View File

@ -34,6 +34,90 @@ use viewer_core::{ConnectConfig, Session};
/// (v5:25→30,尾部三字段是光标;放末尾保证 Kotlin 旧偏移量全部不移动)
const FRAME_HEADER_LEN: usize = 30;
/// 把一个帧的元数据打包成定长头字节(**不含载荷**)。
///
/// 独立成纯函数并被单测钉住的原因:Kotlin 侧 `FrameCodec` 按同样的偏移读这些字节,
/// 任一侧挪一位,两端都编译得过、真机也连得上,只是标记画错位置——静默失败。
/// 单测用**手写字面量**断言(不是"算出来的期望值"),这样 RustBridgeTest 的
/// golden bytes 与这里是同一组常数对拍:改了写入布局,两边至少一边必红。
fn pack_frame_header(f: &viewer_core::Frame) -> [u8; FRAME_HEADER_LEN] {
let mut head = [0u8; FRAME_HEADER_LEN];
head[0] = f.keyframe as u8;
head[1..9].copy_from_slice(&f.seq.to_le_bytes());
head[9..17].copy_from_slice(&f.ts_ms.to_le_bytes());
head[17..21].copy_from_slice(&f.width.to_le_bytes());
head[21..25].copy_from_slice(&f.height.to_le_bytes());
// v5 光标三字段(偏移 25/26/28):None → has=0 且坐标字节保持 0(无意义)。
// 用显式 match 而不是 `f.cursor.is_some() as u8`——两处读同一个 Option,
// 任何「Some 但忘写坐标」的编辑错误都会在这里暴露。
match f.cursor {
Some((x, y)) => {
head[25] = 1;
head[26..28].copy_from_slice(&x.to_le_bytes());
head[28..30].copy_from_slice(&y.to_le_bytes());
}
None => head[25] = 0,
}
head
}
#[cfg(test)]
mod tests {
use super::*;
/// 构造最小 Frame(载荷只放一字节,本测试只关心定长头)。
fn frame(seq: u64, cursor: Option<(i16, i16)>) -> viewer_core::Frame {
viewer_core::Frame {
width: 1920,
height: 1080,
ts_ms: 0x0011_2233_4455_6677,
seq,
keyframe: true,
cursor,
payload: vec![0xAB],
}
}
/// **写端**字面量对拍:期望值逐字节手写,与 Kotlin 侧 RustBridgeTest 的
/// goldenHeader 是同一组常数。两侧各握一份手抄常数,改任何一侧的偏移,
/// 另一侧必红——这才是跨端契约的闭环(golden 测试只锁读端时有盲区:
/// 若 Kotlin 的字面量本身抄错了 Rust 布局,读端测试会跟着一起错还全绿)。
#[test]
fn header_bytes_match_the_declared_layout() {
let head = pack_frame_header(&frame(0x0102_0304_0506_0708, Some((960, 540))));
assert_eq!(
head,
[
0x01, // [0] keyframe=true
0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // [1..9] seq=0x0102..0708 LE
0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, // [9..17] ts LE
0x80, 0x07, 0x00, 0x00, // [17..21] w=1920=0x0780 LE
0x38, 0x04, 0x00, 0x00, // [21..25] h=1080=0x0438 LE
0x01, // [25] has_cursor
0xC0, 0x03, // [26..28] x=960=0x03C0 LE
0x1C, 0x02, // [28..30] y=540=0x021C LE
]
);
}
/// None 分支:has=0 且**坐标字节必须为 0**(vec 初始化保证),Kotlin 侧
/// 测试锁「has=0 时无条件 null」,这里锁「写端不残留脏坐标」。
#[test]
fn none_cursor_writes_zero_flag_and_zero_coords() {
let head = pack_frame_header(&frame(1, None));
assert_eq!(head[25], 0);
assert_eq!(&head[26..30], &[0u8; 4]);
}
/// 负坐标(协议层不该到达,但写端要忠实 i16 位模式——符号截断是静默错位)。
#[test]
fn negative_coords_keep_bit_pattern() {
let head = pack_frame_header(&frame(1, Some((-1, -32768))));
assert_eq!(&head[26..28], &[0xFF, 0xFF]);
assert_eq!(&head[28..30], &[0x00, 0x80]);
}
}
struct SessionBox {
session: Arc<Session>,
runtime: tokio::runtime::Runtime,
@ -167,24 +251,11 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeNextFrame<'local>
match rt.block_on(session.next_frame()) {
None => std::ptr::null_mut(),
Some(f) => {
let mut buf = vec![0u8; FRAME_HEADER_LEN + f.payload.len()];
buf[0] = f.keyframe as u8;
buf[1..9].copy_from_slice(&f.seq.to_le_bytes());
buf[9..17].copy_from_slice(&f.ts_ms.to_le_bytes());
buf[17..21].copy_from_slice(&f.width.to_le_bytes());
buf[21..25].copy_from_slice(&f.height.to_le_bytes());
// v5 光标三字段(偏移 25/26/28):None → has=0 且坐标字节保持 0(无意义)。
// Option 展开成显式分支而不是 f.cursor.is_some() as u8——两处读同一个
// Option,任何「Some 但忘写坐标」的编辑错误都会在这里编译期暴露。
match f.cursor {
Some((x, y)) => {
buf[25] = 1;
buf[26..28].copy_from_slice(&x.to_le_bytes());
buf[28..30].copy_from_slice(&y.to_le_bytes());
}
None => buf[25] = 0,
}
buf[FRAME_HEADER_LEN..].copy_from_slice(&f.payload);
// 布局单一真源是 pack_frame_header(那里有字面量对拍单测)
let head = pack_frame_header(&f);
let mut buf = Vec::with_capacity(FRAME_HEADER_LEN + f.payload.len());
buf.extend_from_slice(head.as_slice());
buf.extend_from_slice(&f.payload);
match env.byte_array_from_slice(&buf) {
Ok(a) => a.into_raw(),
Err(_) => std::ptr::null_mut(),