From 21c3ecfac6d6899fae8168e78be9060e57572a6e Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 21 Apr 2026 14:25:40 -0300 Subject: [PATCH] test(video): cover downstream eye feed --- client/Cargo.toml | 2 +- client/src/launcher/ui_components.rs | 8 +- common/Cargo.toml | 2 +- server/Cargo.toml | 2 +- .../tests/video_downstream_feed_contract.rs | 165 ++++++++++++++++++ 5 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 testing/tests/video_downstream_feed_contract.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index 27de2f7..e5362b7 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.37" +version = "0.11.38" edition = "2024" [dependencies] diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 8808b96..c8b66c3 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -113,9 +113,9 @@ const LAUNCHER_DEFAULT_HEIGHT: i32 = 860; const OPERATIONS_RAIL_WIDTH: i32 = 288; const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 72; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 128; -const DEVICE_PANEL_HEIGHT: i32 = 228; -const DEVICE_TEST_GROUP_HEIGHT: i32 = 188; -const SIDE_LOG_HEIGHT: i32 = 124; +const DEVICE_PANEL_HEIGHT: i32 = 204; +const DEVICE_TEST_GROUP_HEIGHT: i32 = 156; +const SIDE_LOG_HEIGHT: i32 = 104; pub fn build_launcher_view( app: >k::Application, @@ -853,7 +853,7 @@ pub fn install_css(window: >k::ApplicationWindow) { background: rgba(255, 255, 255, 0.08); } progressbar.audio-check-meter.vertical trough { - min-height: 96px; + min-height: 72px; } progressbar.audio-check-meter progress { border-radius: 999px; diff --git a/common/Cargo.toml b/common/Cargo.toml index 26dab39..d98719a 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.37" +version = "0.11.38" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 8102ca9..d9e743f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.37" +version = "0.11.38" edition = "2024" autobins = false diff --git a/testing/tests/video_downstream_feed_contract.rs b/testing/tests/video_downstream_feed_contract.rs new file mode 100644 index 0000000..020b999 --- /dev/null +++ b/testing/tests/video_downstream_feed_contract.rs @@ -0,0 +1,165 @@ +//! 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) + ); + } +} + +#[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]) +}