lesavka/testing/tests/video_downstream_feed_contract.rs

182 lines
5.3 KiB
Rust

//! Downstream eye-video feed contracts.
//!
//! Scope: exercise the server eye stream with a deterministic GStreamer test
//! source and lock down the native source-mode policy used by the launcher and
//! diagnostics.
//! Targets: `server/src/video.rs`, `server/src/video_support.rs`,
//! `common/src/eye_source.rs`.
//! Why: the live capture cards are hardware-dependent, but this catches the
//! packet, timing, keyframe, and mode-selection regressions that break the
//! client previews before hardware enters the loop.
use futures_util::StreamExt;
use lesavka_common::eye_source::{
EyeSourceMode, default_eye_source_mode, display_size_for_source_mode,
eye_source_mode_for_request, native_eye_source_modes,
};
use lesavka_server::{video, video_support::contains_idr};
use serial_test::serial;
use std::time::Duration;
#[test]
fn native_downstream_eye_modes_stay_widescreen_and_square_pixel() {
assert_eq!(
native_eye_source_modes(),
&[
EyeSourceMode {
width: 1920,
height: 1080,
fps: 60,
},
EyeSourceMode {
width: 1280,
height: 720,
fps: 60,
},
]
);
assert_eq!(
default_eye_source_mode(),
EyeSourceMode {
width: 1920,
height: 1080,
fps: 60,
}
);
for mode in native_eye_source_modes() {
assert_eq!(
display_size_for_source_mode(*mode),
(mode.width, mode.height)
);
}
assert_eq!(
display_size_for_source_mode(EyeSourceMode {
width: 720,
height: 576,
fps: 50,
}),
(1024, 576)
);
assert_eq!(
display_size_for_source_mode(EyeSourceMode {
width: 720,
height: 480,
fps: 60,
}),
(854, 480)
);
}
#[test]
fn downstream_eye_request_chooses_best_native_capture_mode() {
assert_eq!(
eye_source_mode_for_request(0, 0),
EyeSourceMode {
width: 1920,
height: 1080,
fps: 60,
}
);
assert_eq!(
eye_source_mode_for_request(1920, 1080),
EyeSourceMode {
width: 1920,
height: 1080,
fps: 60,
}
);
assert_eq!(
eye_source_mode_for_request(1600, 900),
EyeSourceMode {
width: 1280,
height: 720,
fps: 60,
}
);
assert_eq!(
eye_source_mode_for_request(960, 540),
EyeSourceMode {
width: 1280,
height: 720,
fps: 60,
}
);
}
#[test]
#[serial]
fn testsrc_downstream_feed_emits_h264_for_both_eyes() {
with_fast_video_env(|| {
let runtime = tokio::runtime::Runtime::new().expect("runtime");
runtime.block_on(async {
assert_testsrc_downstream_eye(0, 1920, 1080, 60).await;
assert_testsrc_downstream_eye(1, 1280, 720, 60).await;
});
});
}
async fn assert_testsrc_downstream_eye(eye_id: u32, width: u32, height: u32, fps: u32) {
let setup = tokio::time::timeout(
Duration::from_secs(2),
video::eye_ball_with_request("testsrc", eye_id, 1_200, width, height, fps),
)
.await;
let mut stream = match setup {
Ok(Ok(stream)) => stream,
Ok(Err(err)) => panic!("testsrc eye-{eye_id} setup failed: {err:#}"),
Err(_) => panic!("testsrc eye-{eye_id} setup timed out"),
};
let mut last_pts = None;
let mut saw_annex_b = false;
let mut saw_idr = false;
for packet_index in 0..8 {
let packet = tokio::time::timeout(Duration::from_secs(2), stream.next())
.await
.unwrap_or_else(|_| panic!("eye-{eye_id} packet {packet_index} timed out"))
.expect("stream should stay open")
.unwrap_or_else(|err| panic!("eye-{eye_id} packet {packet_index} failed: {err}"));
assert_eq!(packet.id, eye_id, "logical eye id must be preserved");
assert!(
!packet.data.is_empty(),
"eye-{eye_id} packet {packet_index} must carry H.264 bytes"
);
if let Some(previous) = last_pts {
assert!(
packet.pts >= previous,
"eye-{eye_id} PTS must not move backward"
);
}
last_pts = Some(packet.pts);
saw_annex_b |= contains_annex_b_start_code(&packet.data);
saw_idr |= contains_idr(&packet.data);
}
assert!(saw_annex_b, "eye-{eye_id} should emit Annex-B H.264");
assert!(
saw_idr,
"eye-{eye_id} should emit an IDR keyframe for decoder recovery"
);
drop(stream);
}
fn with_fast_video_env(run: impl FnOnce()) {
temp_env::with_vars(
[
("LESAVKA_EYE_ADAPTIVE", Some("0")),
("LESAVKA_EYE_QUEUE_BUFFERS", Some("8")),
("LESAVKA_EYE_APPSINK_BUFFERS", Some("8")),
("LESAVKA_EYE_CHAN_CAPACITY", Some("32")),
("LESAVKA_EYE_TESTSRC_KBIT", Some("1200")),
("LESAVKA_EYE_KEYFRAME_INTERVAL", Some("1")),
],
run,
);
}
fn contains_annex_b_start_code(bytes: &[u8]) -> bool {
bytes.windows(3).any(|window| window == [0, 0, 1])
|| bytes.windows(4).any(|window| window == [0, 0, 0, 1])
}