From 7a4bea63c058d7671379dac574b34b78a5fd4b42 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 12 Apr 2026 15:04:19 -0300 Subject: [PATCH] testing: expand handshake and camera runtime contracts --- testing/tests/handshake_camera_contract.rs | 158 +++++++++++++++++- testing/tests/server_camera_contract.rs | 60 +++++++ .../tests/server_camera_runtime_contract.rs | 58 +++++++ testing/tests/server_uvc_runtime_contract.rs | 44 +++++ 4 files changed, 317 insertions(+), 3 deletions(-) diff --git a/testing/tests/handshake_camera_contract.rs b/testing/tests/handshake_camera_contract.rs index 153d4c5..3418c77 100644 --- a/testing/tests/handshake_camera_contract.rs +++ b/testing/tests/handshake_camera_contract.rs @@ -9,15 +9,22 @@ //! contract test. use lesavka_client::handshake::{PeerCaps, negotiate}; +use lesavka_common::lesavka::{ + Empty, HandshakeSet, + handshake_server::{Handshake, HandshakeServer}, +}; 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; +use tonic::{Request, Response, Status, transport::Server}; -async fn negotiate_against_local_server() -> PeerCaps { +async fn negotiate_against_service(service: S) -> PeerCaps +where + S: Handshake + Send + Sync + 'static, +{ let listener = TcpListener::bind("127.0.0.1:0").expect("bind local handshake listener"); let addr = listener.local_addr().expect("listener addr"); drop(listener); @@ -25,7 +32,7 @@ async fn negotiate_against_local_server() -> PeerCaps { let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let server = tokio::spawn(async move { Server::builder() - .add_service(lesavka_server::handshake::HandshakeSvc::server()) + .add_service(HandshakeServer::new(service)) .serve_with_shutdown(addr, async move { let _ = shutdown_rx.await; }) @@ -40,6 +47,77 @@ async fn negotiate_against_local_server() -> PeerCaps { caps } +async fn negotiate_against_local_server() -> PeerCaps { + negotiate_against_service(lesavka_server::handshake::HandshakeSvc).await +} + +fn assert_default_caps(caps: &PeerCaps) { + assert!(!caps.camera); + assert!(!caps.microphone); + assert_eq!(caps.camera_output, None); + assert_eq!(caps.camera_codec, None); + assert_eq!(caps.camera_width, None); + assert_eq!(caps.camera_height, None); + assert_eq!(caps.camera_fps, None); +} + +struct UnimplementedHandshakeSvc; + +#[tonic::async_trait] +impl Handshake for UnimplementedHandshakeSvc { + async fn get_capabilities( + &self, + _req: Request, + ) -> Result, Status> { + Err(Status::unimplemented("handshake disabled")) + } +} + +struct InternalErrorHandshakeSvc; + +#[tonic::async_trait] +impl Handshake for InternalErrorHandshakeSvc { + async fn get_capabilities( + &self, + _req: Request, + ) -> Result, Status> { + Err(Status::internal("boom")) + } +} + +struct SparseHandshakeSvc; + +#[tonic::async_trait] +impl Handshake for SparseHandshakeSvc { + async fn get_capabilities( + &self, + _req: Request, + ) -> Result, Status> { + Ok(Response::new(HandshakeSet { + camera: true, + microphone: false, + camera_output: String::new(), + camera_codec: String::new(), + camera_width: 0, + camera_height: 0, + camera_fps: 0, + })) + } +} + +struct SlowHandshakeSvc; + +#[tonic::async_trait] +impl Handshake for SlowHandshakeSvc { + async fn get_capabilities( + &self, + _req: Request, + ) -> Result, Status> { + tokio::time::sleep(Duration::from_secs(6)).await; + Ok(Response::new(HandshakeSet::default())) + } +} + #[test] #[serial] fn handshake_returns_uvc_caps_with_explicit_dimensions_and_fps() { @@ -124,3 +202,77 @@ fn handshake_auto_mode_falls_back_to_a_valid_camera_configuration() { assert!(caps.camera); }); } + +#[test] +#[serial] +fn handshake_returns_defaults_when_server_is_unreachable() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); + let addr = listener.local_addr().expect("listener addr"); + drop(listener); + + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate(&format!("http://{addr}"))); + assert_default_caps(&caps); +} + +#[test] +#[serial] +fn handshake_returns_defaults_when_service_is_unimplemented() { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_service(UnimplementedHandshakeSvc)); + assert_default_caps(&caps); +} + +#[test] +#[serial] +fn handshake_returns_defaults_when_service_returns_internal_error() { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_service(InternalErrorHandshakeSvc)); + assert_default_caps(&caps); +} + +#[test] +#[serial] +fn handshake_maps_empty_optional_fields_to_none() { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_service(SparseHandshakeSvc)); + assert!(caps.camera); + assert!(!caps.microphone); + assert_eq!(caps.camera_output, None); + assert_eq!(caps.camera_codec, None); + assert_eq!(caps.camera_width, None); + assert_eq!(caps.camera_height, None); + assert_eq!(caps.camera_fps, None); +} + +#[test] +#[serial] +fn handshake_times_out_slow_capabilities_call() { + let rt = Runtime::new().expect("create runtime"); + let caps = rt.block_on(negotiate_against_service(SlowHandshakeSvc)); + assert_default_caps(&caps); +} + +#[test] +#[serial] +fn handshake_service_direct_call_reports_capabilities() { + with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { + let rt = Runtime::new().expect("create runtime"); + let response = rt.block_on(async { + lesavka_server::handshake::HandshakeSvc + .get_capabilities(Request::new(Empty {})) + .await + .expect("handshake service response") + .into_inner() + }); + assert!(response.camera); + assert!(response.microphone); + assert_eq!(response.camera_output, "uvc"); + assert_eq!(response.camera_codec, "mjpeg"); + assert!(response.camera_width > 0); + assert!(response.camera_height > 0); + assert!(response.camera_fps > 0); + + let _ = lesavka_server::handshake::HandshakeSvc::server(); + }); +} diff --git a/testing/tests/server_camera_contract.rs b/testing/tests/server_camera_contract.rs index a0f1182..8527cbe 100644 --- a/testing/tests/server_camera_contract.rs +++ b/testing/tests/server_camera_contract.rs @@ -111,3 +111,63 @@ fn camera_config_output_override_is_case_insensitive() { }); }); } + +#[test] +#[serial] +fn camera_config_defaults_when_uvc_dimensions_and_rate_are_missing() { + with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { + with_var("LESAVKA_UVC_WIDTH", None::<&str>, || { + with_var("LESAVKA_UVC_HEIGHT", None::<&str>, || { + with_var("LESAVKA_UVC_FPS", None::<&str>, || { + with_var("LESAVKA_UVC_INTERVAL", None::<&str>, || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Uvc); + assert_eq!(cfg.codec, CameraCodec::Mjpeg); + assert_eq!(cfg.width, 1280); + assert_eq!(cfg.height, 720); + assert_eq!(cfg.fps, 25); + }); + }); + }); + }); + }); +} + +#[test] +#[serial] +fn camera_config_prefers_explicit_fps_over_interval() { + with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { + with_var("LESAVKA_UVC_WIDTH", Some("800"), || { + with_var("LESAVKA_UVC_HEIGHT", Some("600"), || { + with_var("LESAVKA_UVC_FPS", Some("48"), || { + with_var("LESAVKA_UVC_INTERVAL", Some("250000"), || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Uvc); + assert_eq!(cfg.codec, CameraCodec::Mjpeg); + assert_eq!(cfg.width, 800); + assert_eq!(cfg.height, 600); + assert_eq!(cfg.fps, 48); + }); + }); + }); + }); + }); +} + +#[test] +#[serial] +fn camera_config_invalid_output_falls_back_to_detected_policy() { + with_var("LESAVKA_CAM_OUTPUT", Some("not-a-real-mode"), || { + let cfg = update_camera_config(); + match cfg.output { + CameraOutput::Uvc => { + assert_eq!(cfg.codec, CameraCodec::Mjpeg); + assert!(cfg.fps > 0); + } + CameraOutput::Hdmi => { + assert_eq!(cfg.codec, CameraCodec::H264); + assert_eq!(cfg.fps, 30); + } + } + }); +} diff --git a/testing/tests/server_camera_runtime_contract.rs b/testing/tests/server_camera_runtime_contract.rs index 0d391fc..3f921e1 100644 --- a/testing/tests/server_camera_runtime_contract.rs +++ b/testing/tests/server_camera_runtime_contract.rs @@ -39,6 +39,33 @@ fn activate_rejects_uvc_when_disabled_and_bumps_generation() { assert!(!runtime.is_active(2)); } +#[test] +#[serial] +fn activate_tracks_latest_generation_across_repeated_failures() { + let runtime = CameraRuntime::new(); + let cfg = CameraConfig { + output: CameraOutput::Uvc, + codec: CameraCodec::Mjpeg, + width: 640, + height: 360, + fps: 30, + hdmi: None, + }; + + with_var("LESAVKA_DISABLE_UVC", Some("1"), || { + let rt = Runtime::new().expect("runtime"); + for expected in [1_u64, 2_u64, 3_u64] { + let result = rt.block_on(runtime.activate(&cfg)); + match result { + Ok(_) => panic!("UVC should remain disabled"), + Err(err) => assert_eq!(err.code(), Code::FailedPrecondition), + } + assert!(runtime.is_active(expected)); + assert!(!runtime.is_active(expected + 1)); + } + }); +} + #[test] fn camera_cfg_eq_handles_none_and_hdmi_connector_edges() { let uvc_a = CameraConfig { @@ -77,3 +104,34 @@ fn camera_cfg_eq_handles_none_and_hdmi_connector_edges() { missing_connector.hdmi = None; assert!(!camera_cfg_eq(&missing_connector, &hdmi_b)); } + +#[test] +fn camera_cfg_eq_rejects_output_codec_resolution_and_fps_changes() { + let base = CameraConfig { + output: CameraOutput::Uvc, + codec: CameraCodec::Mjpeg, + width: 1280, + height: 720, + fps: 25, + hdmi: None, + }; + let mut changed = base.clone(); + changed.output = CameraOutput::Hdmi; + assert!(!camera_cfg_eq(&base, &changed)); + + changed = base.clone(); + changed.codec = CameraCodec::H264; + assert!(!camera_cfg_eq(&base, &changed)); + + changed = base.clone(); + changed.width = 1920; + assert!(!camera_cfg_eq(&base, &changed)); + + changed = base.clone(); + changed.height = 1080; + assert!(!camera_cfg_eq(&base, &changed)); + + changed = base.clone(); + changed.fps = 30; + assert!(!camera_cfg_eq(&base, &changed)); +} diff --git a/testing/tests/server_uvc_runtime_contract.rs b/testing/tests/server_uvc_runtime_contract.rs index 73d7405..1558185 100644 --- a/testing/tests/server_uvc_runtime_contract.rs +++ b/testing/tests/server_uvc_runtime_contract.rs @@ -55,6 +55,50 @@ fn supervise_uvc_control_starts_helper_when_device_is_set() { ); } +#[test] +#[serial] +fn supervise_uvc_control_restarts_helper_after_exit() { + let dir = tempdir().expect("create temp dir"); + let marker = dir.path().join("marker.log"); + let helper = dir.path().join("helper.sh"); + fs::write( + &helper, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\necho \"$*\" >> '{}'\nexit 0\n", + marker.display() + ), + ) + .expect("write helper script"); + let mut perms = fs::metadata(&helper) + .expect("helper metadata") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&helper, perms).expect("chmod helper script"); + + let helper_path = helper.to_string_lossy().to_string(); + with_var("LESAVKA_UVC_DEV", Some("/dev/video-loop"), || { + let rt = Runtime::new().expect("runtime"); + let result = rt.block_on(async { + tokio::time::timeout( + Duration::from_millis(2_450), + supervise_uvc_control(helper_path), + ) + .await + }); + assert!(result.is_err(), "supervisor should still be running"); + }); + + let calls = fs::read_to_string(marker).expect("read helper marker"); + let restart_count = calls + .lines() + .filter(|line| line.contains("--device /dev/video-loop")) + .count(); + assert!( + restart_count >= 2, + "expected helper restart loop to run at least twice, got {restart_count}: {calls}" + ); +} + #[test] #[serial] fn supervise_uvc_control_survives_missing_helper_binary() {