testing: expand handshake and camera runtime contracts

This commit is contained in:
Brad Stein 2026-04-12 15:04:19 -03:00
parent 3533b46eb8
commit 7a4bea63c0
4 changed files with 317 additions and 3 deletions

View File

@ -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<S>(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<Empty>,
) -> Result<Response<HandshakeSet>, Status> {
Err(Status::unimplemented("handshake disabled"))
}
}
struct InternalErrorHandshakeSvc;
#[tonic::async_trait]
impl Handshake for InternalErrorHandshakeSvc {
async fn get_capabilities(
&self,
_req: Request<Empty>,
) -> Result<Response<HandshakeSet>, Status> {
Err(Status::internal("boom"))
}
}
struct SparseHandshakeSvc;
#[tonic::async_trait]
impl Handshake for SparseHandshakeSvc {
async fn get_capabilities(
&self,
_req: Request<Empty>,
) -> Result<Response<HandshakeSet>, 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<Empty>,
) -> Result<Response<HandshakeSet>, 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();
});
}

View File

@ -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);
}
}
});
}

View File

@ -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));
}

View File

@ -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() {