From 31d4ee47b4587f4e9ee16add8b02c57046cfca5b Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 11 Apr 2026 03:36:42 -0300 Subject: [PATCH] test(gate): tighten coverage contract --- server/src/lib.rs | 2 +- testing/Cargo.toml | 3 + testing/coverage_contract.json | 6 +- testing/tests/handshake_camera_contract.rs | 126 ++++++++++++++++++++ testing/tests/video_support_contract.rs | 129 +++++++++++++++++++++ 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 testing/tests/handshake_camera_contract.rs create mode 100644 testing/tests/video_support_contract.rs diff --git a/server/src/lib.rs b/server/src/lib.rs index bd9777a..be721ac 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -10,4 +10,4 @@ pub mod runtime_support; pub mod uvc_runtime; pub mod video; pub(crate) mod video_sinks; -pub(crate) mod video_support; +pub mod video_support; diff --git a/testing/Cargo.toml b/testing/Cargo.toml index bfc892b..4a54cd0 100644 --- a/testing/Cargo.toml +++ b/testing/Cargo.toml @@ -15,6 +15,9 @@ lesavka_client = { path = "../client" } lesavka_common = { path = "../common" } lesavka_server = { path = "../server" } chacha20poly1305 = "0.10" +gstreamer = { version = "0.23", features = ["v1_22"] } serial_test = { workspace = true } temp-env = { workspace = true } tempfile = { workspace = true } +tokio = { version = "1.45", features = ["full", "macros", "rt-multi-thread", "sync", "time"] } +tonic = { version = "0.13", features = ["transport"] } diff --git a/testing/coverage_contract.json b/testing/coverage_contract.json index cc2ff31..a4afa1d 100644 --- a/testing/coverage_contract.json +++ b/testing/coverage_contract.json @@ -3,8 +3,12 @@ "files": [ "client/src/app_support.rs", "client/src/input/keymap.rs", + "client/src/output/layout.rs", + "common/src/cli.rs", "common/src/hid.rs", "common/src/paste.rs", - "server/src/paste.rs" + "server/src/handshake.rs", + "server/src/paste.rs", + "server/src/video_support.rs" ] } diff --git a/testing/tests/handshake_camera_contract.rs b/testing/tests/handshake_camera_contract.rs new file mode 100644 index 0000000..153d4c5 --- /dev/null +++ b/testing/tests/handshake_camera_contract.rs @@ -0,0 +1,126 @@ +//! Integration coverage for the handshake and camera-selection contract. +//! +//! Scope: exercise the real client handshake against a local server and the +//! server camera selection logic that feeds the handshake response. +//! Targets: `client/src/handshake.rs`, `server/src/handshake.rs`, and +//! `server/src/camera.rs`. +//! Why: the handshake path is the narrow entrypoint that decides whether the +//! client and server agree on output mode, so it deserves a centralized +//! contract test. + +use lesavka_client::handshake::{PeerCaps, negotiate}; +use serial_test::serial; +use std::net::TcpListener; +use std::time::Duration; +use temp_env::with_var; +use tokio::runtime::Runtime; +use tokio::sync::oneshot; +use tonic::transport::Server; + +async fn negotiate_against_local_server() -> PeerCaps { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind local handshake listener"); + let addr = listener.local_addr().expect("listener addr"); + drop(listener); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let server = tokio::spawn(async move { + Server::builder() + .add_service(lesavka_server::handshake::HandshakeSvc::server()) + .serve_with_shutdown(addr, async move { + let _ = shutdown_rx.await; + }) + .await + .expect("serve handshake server"); + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + let caps = negotiate(&format!("http://{addr}")).await; + let _ = shutdown_tx.send(()); + let _ = server.await; + caps +} + +#[test] +#[serial] +fn handshake_returns_uvc_caps_with_explicit_dimensions_and_fps() { + with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { + with_var("LESAVKA_UVC_WIDTH", Some("1024"), || { + with_var("LESAVKA_UVC_HEIGHT", Some("576"), || { + with_var("LESAVKA_UVC_FPS", Some("12"), || { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_local_server()); + assert!(caps.camera); + assert!(caps.microphone); + assert_eq!(caps.camera_output, Some(String::from("uvc"))); + assert_eq!(caps.camera_codec, Some(String::from("mjpeg"))); + assert_eq!(caps.camera_width, Some(1024)); + assert_eq!(caps.camera_height, Some(576)); + assert_eq!(caps.camera_fps, Some(12)); + }); + }); + }); + }); +} + +#[test] +#[serial] +fn handshake_uses_uvc_interval_when_fps_is_unset() { + with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { + with_var("LESAVKA_UVC_WIDTH", Some("640"), || { + with_var("LESAVKA_UVC_HEIGHT", Some("360"), || { + with_var("LESAVKA_UVC_INTERVAL", Some("250000"), || { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_local_server()); + assert_eq!(caps.camera_output, Some(String::from("uvc"))); + assert_eq!(caps.camera_codec, Some(String::from("mjpeg"))); + assert_eq!(caps.camera_width, Some(640)); + assert_eq!(caps.camera_height, Some(360)); + assert_eq!(caps.camera_fps, Some(40)); + assert!(caps.camera); + assert!(caps.microphone); + }); + }); + }); + }); +} + +#[test] +#[serial] +fn handshake_returns_hdmi_caps_with_h264_codec() { + with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || { + with_var("LESAVKA_DISABLE_UAC", Some("1"), || { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_local_server()); + assert_eq!(caps.camera_output, Some(String::from("hdmi"))); + assert_eq!(caps.camera_codec, Some(String::from("h264"))); + assert_eq!(caps.camera_fps, Some(30)); + assert!(!caps.microphone); + assert!(caps.camera); + assert!(matches!(caps.camera_width, Some(1280) | Some(1920))); + assert!(matches!(caps.camera_height, Some(720) | Some(1080))); + }); + }); +} + +#[test] +#[serial] +fn handshake_auto_mode_falls_back_to_a_valid_camera_configuration() { + with_var("LESAVKA_CAM_OUTPUT", Some("auto"), || { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_local_server()); + match caps.camera_output.as_deref() { + Some("uvc") => { + assert_eq!(caps.camera_codec.as_deref(), Some("mjpeg")); + assert!(matches!(caps.camera_width, Some(1280) | Some(640))); + assert!(matches!(caps.camera_height, Some(720) | Some(360))); + } + Some("hdmi") => { + assert_eq!(caps.camera_codec.as_deref(), Some("h264")); + assert!(matches!(caps.camera_width, Some(1280) | Some(1920))); + assert!(matches!(caps.camera_height, Some(720) | Some(1080))); + } + other => panic!("unexpected camera output: {other:?}"), + } + assert!(caps.camera); + }); +} diff --git a/testing/tests/video_support_contract.rs b/testing/tests/video_support_contract.rs new file mode 100644 index 0000000..2f9aa0b --- /dev/null +++ b/testing/tests/video_support_contract.rs @@ -0,0 +1,129 @@ +//! Integration coverage for the server video-support helpers. +//! +//! Scope: exercise the pure policy helpers and cached env flag behavior from +//! the top-level testing module. +//! Targets: `server/src/video_support.rs`. +//! Why: these helpers are small but central to the adaptive video policy, so +//! they are a good fit for a centralized contract test. + +use gstreamer as gst; +use serial_test::serial; +use std::process::Command; +use temp_env::with_var; + +use lesavka_server::video_support::{ + adjust_effective_fps, contains_idr, default_eye_fps, dev_mode_enabled, env_u32, env_usize, + next_local_pts, pick_h264_decoder, should_send_frame, +}; + +#[test] +fn default_eye_fps_tracks_the_expected_bitrate_steps() { + assert_eq!(default_eye_fps(0), 25); + assert_eq!(default_eye_fps(2_500), 15); + assert_eq!(default_eye_fps(2_501), 20); + assert_eq!(default_eye_fps(4_001), 25); +} + +#[test] +fn contains_idr_handles_short_and_multi_nal_annex_b_streams() { + assert!(contains_idr(&[0, 0, 1, 0x65, 0x00])); + assert!(contains_idr(&[0, 0, 0, 1, 0x41, 0x00, 0, 0, 1, 0x65, 0x00])); + assert!(!contains_idr(&[0, 0, 0, 1, 0x41, 0x00, 0x00])); +} + +#[test] +fn adjust_effective_fps_and_timestamp_helpers_stay_stable() { + assert_eq!(adjust_effective_fps(20, 12, 25, 5, 10), 17); + assert_eq!(adjust_effective_fps(20, 12, 25, 0, 20), 21); + assert_eq!(adjust_effective_fps(12, 12, 25, 10, 10), 12); + assert_eq!(adjust_effective_fps(18, 12, 25, 0, 0), 18); + + assert!(should_send_frame(0, 10, 25)); + assert!(!should_send_frame(40_000, 50_000, 25)); + assert!(should_send_frame(40_000, 90_000, 25)); + + let counter = std::sync::atomic::AtomicU64::new(0); + assert_eq!(next_local_pts(&counter, 40_000), 0); + assert_eq!(next_local_pts(&counter, 40_000), 40_000); +} + +#[test] +#[serial] +fn env_helpers_parse_and_fallback_without_panicking() { + with_var("LESAVKA_TEST_U32", Some("42"), || { + assert_eq!(env_u32("LESAVKA_TEST_U32", 7), 42); + }); + with_var("LESAVKA_TEST_U32", Some("oops"), || { + assert_eq!(env_u32("LESAVKA_TEST_U32", 7), 7); + }); + with_var("LESAVKA_TEST_USIZE", Some("128"), || { + assert_eq!(env_usize("LESAVKA_TEST_USIZE", 64), 128); + }); + with_var("LESAVKA_TEST_USIZE", None::<&str>, || { + assert_eq!(env_usize("LESAVKA_TEST_USIZE", 64), 64); + }); +} + +#[test] +fn pick_h264_decoder_returns_a_known_decoder_name() { + gst::init().expect("initialize gstreamer"); + assert!(matches!( + pick_h264_decoder(), + "v4l2h264dec" | "v4l2slh264dec" | "omxh264dec" | "avdec_h264" + )); +} + +fn run_ignored_probe(test_name: &str, envs: &[(&str, Option<&str>)]) -> std::process::Output { + let exe = std::env::current_exe().expect("resolve test binary"); + let mut cmd = Command::new(exe); + cmd.arg(test_name).arg("--ignored").arg("--exact"); + for (key, value) in envs { + match value { + Some(v) => { + cmd.env(key, v); + } + None => { + cmd.env_remove(key); + } + } + } + cmd.output().expect("run probe") +} + +#[test] +#[serial] +fn dev_mode_flag_is_cached_per_process() { + let enabled = run_ignored_probe( + "probe_dev_mode_enabled_true", + &[("LESAVKA_DEV_MODE", Some("1"))], + ); + assert!( + enabled.status.success(), + "expected dev-mode probe to pass:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&enabled.stdout), + String::from_utf8_lossy(&enabled.stderr) + ); + + let disabled = run_ignored_probe( + "probe_dev_mode_enabled_false", + &[("LESAVKA_DEV_MODE", None)], + ); + assert!( + disabled.status.success(), + "expected dev-mode false probe to pass:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&disabled.stdout), + String::from_utf8_lossy(&disabled.stderr) + ); +} + +#[test] +#[ignore] +fn probe_dev_mode_enabled_true() { + assert!(dev_mode_enabled()); +} + +#[test] +#[ignore] +fn probe_dev_mode_enabled_false() { + assert!(!dev_mode_enabled()); +}