474 lines
16 KiB
Rust
474 lines
16 KiB
Rust
//! Include-based coverage for camera capture configuration branches.
|
|
//!
|
|
//! Scope: include `client/src/input/camera.rs` and exercise encoder/source
|
|
//! selection helpers plus non-device fallbacks.
|
|
//! Targets: `client/src/input/camera.rs`.
|
|
//! Why: camera startup should remain robust across codec/env permutations.
|
|
|
|
#[allow(warnings)]
|
|
mod live_capture_clock {
|
|
include!("support/live_capture_clock_shim.rs");
|
|
}
|
|
|
|
#[allow(warnings)]
|
|
mod camera_include_contract {
|
|
include!(env!("LESAVKA_CLIENT_CAMERA_SRC"));
|
|
|
|
use serial_test::serial;
|
|
use std::os::unix::fs::symlink;
|
|
use temp_env::with_var;
|
|
use tempfile::tempdir;
|
|
|
|
fn init_gst() {
|
|
gst::init().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn env_u32_parses_values_and_falls_back() {
|
|
with_var("LESAVKA_TEST_CAM_U32", Some("77"), || {
|
|
assert_eq!(env_u32("LESAVKA_TEST_CAM_U32", 11), 77);
|
|
});
|
|
with_var("LESAVKA_TEST_CAM_U32", Some("not-a-number"), || {
|
|
assert_eq!(env_u32("LESAVKA_TEST_CAM_U32", 11), 11);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn camera_source_profile_defaults_to_auto_decode_for_v4l2_sources() {
|
|
with_var("LESAVKA_CAM_MJPG", None::<&str>, || {
|
|
with_var("LESAVKA_CAM_FORMAT", None::<&str>, || {
|
|
assert_eq!(camera_source_profile(true), CameraSourceProfile::AutoDecode);
|
|
assert_eq!(camera_source_profile(false), CameraSourceProfile::Raw);
|
|
});
|
|
});
|
|
|
|
with_var("LESAVKA_CAM_FORMAT", Some("raw"), || {
|
|
assert_eq!(camera_source_profile(true), CameraSourceProfile::Raw);
|
|
});
|
|
with_var("LESAVKA_CAM_FORMAT", Some("mjpeg"), || {
|
|
assert_eq!(camera_source_profile(true), CameraSourceProfile::Mjpeg);
|
|
});
|
|
with_var("LESAVKA_CAM_MJPG", Some("1"), || {
|
|
assert_eq!(camera_source_profile(true), CameraSourceProfile::Mjpeg);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn camera_auto_decode_caps_accept_raw_and_mjpeg_at_requested_profile() {
|
|
let caps = camera_auto_decode_caps(1280, 720, 30);
|
|
assert!(caps.contains("video/x-raw,width=(int)1280,height=(int)720"));
|
|
assert!(caps.contains("image/jpeg,width=(int)1280,height=(int)720"));
|
|
assert!(caps.contains("framerate=(fraction)30/1"));
|
|
|
|
let chain = camera_raw_source_chain(
|
|
"v4l2src device=/dev/video0 do-timestamp=true",
|
|
"video/x-raw,width=1280,height=720,framerate=30/1",
|
|
1280,
|
|
720,
|
|
30,
|
|
CameraSourceProfile::AutoDecode,
|
|
);
|
|
assert!(chain.contains("decodebin ! videoconvert ! videoscale ! videorate"));
|
|
assert!(chain.contains("capsfilter caps=\""));
|
|
}
|
|
|
|
#[test]
|
|
fn camera_preview_tap_uses_the_actual_uplink_dimensions() {
|
|
let branch = camera_preview_tap_branch(1920, 1080, 30);
|
|
assert!(branch.contains("width=1920,height=1080"));
|
|
assert!(branch.contains("framerate=30/1"));
|
|
assert!(!branch.contains("width=128,height=72"));
|
|
|
|
let branch = camera_preview_tap_branch(1280, 720, 120);
|
|
assert!(branch.contains("width=1280,height=720"));
|
|
assert!(
|
|
branch.contains("framerate=60/1"),
|
|
"preview tap should cap frame production while preserving resolution"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn encoder_helpers_return_supported_defaults() {
|
|
init_gst();
|
|
let (enc, _caps) = CameraCapture::pick_encoder();
|
|
assert!(
|
|
matches!(
|
|
enc,
|
|
"nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"
|
|
),
|
|
"unexpected encoder: {enc}"
|
|
);
|
|
let (enc, key_prop) = CameraCapture::choose_encoder();
|
|
assert!(
|
|
matches!(
|
|
enc,
|
|
"nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"
|
|
),
|
|
"unexpected encoder: {enc}"
|
|
);
|
|
if let Some(key_prop) = key_prop {
|
|
assert!(!key_prop.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
fn camera_bus_logger_coverage_stub_is_non_blocking() {
|
|
init_gst();
|
|
let pipeline = gst::Pipeline::new();
|
|
spawn_camera_bus_logger(&pipeline, "test-camera".to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn find_device_and_capture_detection_handle_missing_nodes() {
|
|
assert!(CameraCapture::find_device("never-matches-this-fragment").is_none());
|
|
assert!(!CameraCapture::is_capture(
|
|
"/dev/definitely-missing-camera0"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
#[serial]
|
|
fn find_device_normalizes_spaces_underscores_and_case() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let by_id = dir.path().join("by-id");
|
|
std::fs::create_dir_all(&by_id).expect("create by-id");
|
|
symlink(
|
|
"../video42",
|
|
by_id.join("usb-046d_Logitech_BRIO-video-index0"),
|
|
)
|
|
.expect("create camera symlink");
|
|
|
|
with_var(
|
|
"LESAVKA_CAM_BY_ID_DIR",
|
|
Some(by_id.to_string_lossy().to_string()),
|
|
|| {
|
|
with_var("LESAVKA_CAM_DEV_ROOT", Some("/dev".to_string()), || {
|
|
let found = CameraCapture::find_device("Logitech BRIO");
|
|
assert_eq!(found.as_deref(), Some("/dev/video42"));
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn find_device_honors_override_roots_and_handles_non_capture_targets() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let by_id = dir.path().join("by-id");
|
|
let dev_root = dir.path().join("dev-root");
|
|
std::fs::create_dir_all(&by_id).expect("create by-id");
|
|
std::fs::create_dir_all(&dev_root).expect("create dev root");
|
|
std::fs::write(dev_root.join("video42"), "").expect("create fake node");
|
|
symlink("../dev-root/video42", by_id.join("usb-Cam_42")).expect("create camera symlink");
|
|
|
|
with_var(
|
|
"LESAVKA_CAM_BY_ID_DIR",
|
|
Some(by_id.to_string_lossy().to_string()),
|
|
|| {
|
|
with_var(
|
|
"LESAVKA_CAM_DEV_ROOT",
|
|
Some(dev_root.to_string_lossy().to_string()),
|
|
|| {
|
|
let found = CameraCapture::find_device("Cam_42");
|
|
assert!(
|
|
found.is_none(),
|
|
"fake file should not pass V4L capture capability checks"
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
#[serial]
|
|
fn find_device_returns_dev_path_when_fake_target_matches_capture_shape() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let by_id = dir.path().join("by-id");
|
|
std::fs::create_dir_all(&by_id).expect("create by-id");
|
|
symlink("../video42", by_id.join("usb-Cam_42")).expect("create camera symlink");
|
|
|
|
with_var(
|
|
"LESAVKA_CAM_BY_ID_DIR",
|
|
Some(by_id.to_string_lossy().to_string()),
|
|
|| {
|
|
with_var("LESAVKA_CAM_DEV_ROOT", Some("/dev".to_string()), || {
|
|
let found = CameraCapture::find_device("Cam_42");
|
|
assert_eq!(found.as_deref(), Some("/dev/video42"));
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn new_covers_test_pattern_and_mjpg_source_branches() {
|
|
init_gst();
|
|
let _ = CameraCapture::new(Some("test"), None);
|
|
|
|
with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || {
|
|
let _ = CameraCapture::new(Some("test"), None);
|
|
});
|
|
|
|
let mjpeg_cfg = CameraConfig {
|
|
codec: CameraCodec::Mjpeg,
|
|
width: 640,
|
|
height: 480,
|
|
fps: 30,
|
|
};
|
|
let _ = CameraCapture::new(Some("test"), Some(mjpeg_cfg));
|
|
|
|
with_var("LESAVKA_CAM_MJPG", Some("1"), || {
|
|
let _ = CameraCapture::new(Some("/dev/video0"), None);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn active_camera_capture_can_publish_local_preview_tap() {
|
|
init_gst();
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("uplink-camera-preview.rgba");
|
|
let cfg = CameraConfig {
|
|
codec: CameraCodec::H264,
|
|
width: 160,
|
|
height: 90,
|
|
fps: 10,
|
|
};
|
|
|
|
with_var(
|
|
"LESAVKA_UPLINK_CAMERA_PREVIEW",
|
|
Some(path.to_string_lossy().to_string()),
|
|
|| {
|
|
let Ok(cap) = CameraCapture::new(Some("test"), Some(cfg)) else {
|
|
return;
|
|
};
|
|
|
|
for _ in 0..30 {
|
|
let _ = cap.pull();
|
|
if let Ok(bytes) = std::fs::read(&path) {
|
|
assert!(
|
|
bytes.starts_with(b"LESAVKA_RGBA "),
|
|
"preview tap should publish an RGBA frame header"
|
|
);
|
|
let header_end = bytes
|
|
.iter()
|
|
.position(|byte| *byte == b'\n')
|
|
.expect("preview header newline");
|
|
let header =
|
|
std::str::from_utf8(&bytes[..header_end]).expect("utf8 header");
|
|
assert!(
|
|
header.starts_with("LESAVKA_RGBA 160 90 "),
|
|
"preview tap should prove the selected uplink size, got {header:?}"
|
|
);
|
|
return;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
}
|
|
panic!("camera preview tap did not publish a frame");
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn spawned_camera_preview_tap_tolerates_publish_errors() {
|
|
init_gst();
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("missing-parent").join("preview.rgba");
|
|
let pipeline: gst::Pipeline = gst::parse::launch(
|
|
"appsrc name=src is-live=true format=time caps=video/x-raw,format=RGBA,width=2,height=2,framerate=1/1 ! \
|
|
appsink name=sink emit-signals=false sync=false max-buffers=4 drop=true",
|
|
)
|
|
.expect("pipeline")
|
|
.downcast()
|
|
.expect("pipeline cast");
|
|
let src: gst_app::AppSrc = pipeline
|
|
.by_name("src")
|
|
.expect("appsrc")
|
|
.downcast()
|
|
.expect("appsrc cast");
|
|
let sink: gst_app::AppSink = pipeline
|
|
.by_name("sink")
|
|
.expect("appsink")
|
|
.downcast()
|
|
.expect("appsink cast");
|
|
pipeline.set_state(gst::State::Playing).expect("playing");
|
|
|
|
let running = spawn_camera_preview_tap(sink, path);
|
|
src.push_buffer(gst::Buffer::from_slice(vec![255_u8; 16]))
|
|
.expect("push buffer");
|
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
|
|
|
running.store(false, Ordering::Release);
|
|
let _ = pipeline.set_state(gst::State::Null);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn new_covers_preview_tap_output_format_combinations() {
|
|
init_gst();
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("preview.rgba");
|
|
let mjpeg_cfg = CameraConfig {
|
|
codec: CameraCodec::Mjpeg,
|
|
width: 320,
|
|
height: 240,
|
|
fps: 15,
|
|
};
|
|
let h264_cfg = CameraConfig {
|
|
codec: CameraCodec::H264,
|
|
width: 320,
|
|
height: 240,
|
|
fps: 15,
|
|
};
|
|
|
|
with_var(
|
|
"LESAVKA_UPLINK_CAMERA_PREVIEW",
|
|
Some(path.to_string_lossy().to_string()),
|
|
|| {
|
|
let mjpeg_out = CameraCapture::new(Some("/dev/video42"), Some(mjpeg_cfg));
|
|
assert!(mjpeg_out.is_ok() || mjpeg_out.is_err());
|
|
|
|
with_var("LESAVKA_CAM_MJPG", Some("1"), || {
|
|
let mjpeg_passthrough =
|
|
CameraCapture::new(Some("/dev/video42"), Some(mjpeg_cfg));
|
|
assert!(mjpeg_passthrough.is_ok() || mjpeg_passthrough.is_err());
|
|
|
|
let mjpeg_to_h264 = CameraCapture::new(Some("/dev/video42"), Some(h264_cfg));
|
|
assert!(mjpeg_to_h264.is_ok() || mjpeg_to_h264.is_err());
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn new_stub_and_pull_are_stable_without_frames() {
|
|
init_gst();
|
|
let stub = CameraCapture::new_stub();
|
|
assert!(stub.pull().is_none());
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn new_covers_device_path_fragment_and_default_source_branches() {
|
|
init_gst();
|
|
|
|
let by_path = CameraCapture::new(Some("/dev/video42"), None);
|
|
assert!(by_path.is_ok() || by_path.is_err());
|
|
|
|
let by_fragment = CameraCapture::new(Some("definitely-missing-fragment"), None);
|
|
assert!(by_fragment.is_ok() || by_fragment.is_err());
|
|
|
|
let default_source = CameraCapture::new(None, None);
|
|
assert!(default_source.is_ok() || default_source.is_err());
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn new_covers_output_codec_and_mjpg_source_switches() {
|
|
init_gst();
|
|
|
|
let mjpeg_cfg = CameraConfig {
|
|
codec: CameraCodec::Mjpeg,
|
|
width: 320,
|
|
height: 240,
|
|
fps: 15,
|
|
};
|
|
let mjpeg_out = CameraCapture::new(Some("/dev/video42"), Some(mjpeg_cfg));
|
|
assert!(mjpeg_out.is_ok() || mjpeg_out.is_err());
|
|
|
|
with_var("LESAVKA_CAM_MJPG", Some("1"), || {
|
|
let mjpeg_passthrough = CameraCapture::new(Some("/dev/video42"), Some(mjpeg_cfg));
|
|
assert!(mjpeg_passthrough.is_ok() || mjpeg_passthrough.is_err());
|
|
|
|
let h264_cfg = CameraConfig {
|
|
codec: CameraCodec::H264,
|
|
width: 640,
|
|
height: 480,
|
|
fps: 25,
|
|
};
|
|
let mjpg_source = CameraCapture::new(Some("/dev/video42"), Some(h264_cfg));
|
|
assert!(mjpg_source.is_ok() || mjpg_source.is_err());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
#[serial]
|
|
fn new_covers_non_x264_encoder_option_branch_in_coverage_harness() {
|
|
init_gst();
|
|
let cfg = CameraConfig {
|
|
codec: CameraCodec::H264,
|
|
width: 640,
|
|
height: 480,
|
|
fps: 30,
|
|
};
|
|
|
|
with_var("LESAVKA_CAM_TEST_ENCODER", Some("v4l2h264enc"), || {
|
|
let result = CameraCapture::new(Some("test"), Some(cfg));
|
|
assert!(result.is_ok() || result.is_err());
|
|
});
|
|
assert_eq!(
|
|
CameraCapture::encoder_options("nvh264enc", None, 30),
|
|
"nvh264enc"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn pull_returns_packet_from_test_pattern_pipeline_when_available() {
|
|
init_gst();
|
|
let cfg = CameraConfig {
|
|
codec: CameraCodec::H264,
|
|
width: 320,
|
|
height: 240,
|
|
fps: 15,
|
|
};
|
|
match CameraCapture::new(Some("test"), Some(cfg)) {
|
|
Ok(cap) => {
|
|
for _ in 0..20 {
|
|
if let Some(pkt) = cap.pull() {
|
|
assert_eq!(pkt.id, 2);
|
|
assert!(
|
|
!pkt.data.is_empty(),
|
|
"test pattern should emit payload bytes"
|
|
);
|
|
return;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
|
}
|
|
}
|
|
Err(err) => {
|
|
assert!(!err.to_string().trim().is_empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn camera_timing_helpers_cover_first_packet_and_trace_enabled_paths() {
|
|
log_camera_first_packet(0, 128, 42_000);
|
|
assert!(!should_log_camera_timing_sample(11));
|
|
|
|
with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || {
|
|
assert!(should_log_camera_timing_sample(0));
|
|
log_camera_timing_sample(
|
|
0,
|
|
crate::live_capture_clock::RebasedSourcePts {
|
|
packet_pts_us: 12_345,
|
|
capture_now_us: 12_999,
|
|
source_pts_us: Some(5_000),
|
|
source_base_us: Some(5_000),
|
|
capture_base_us: Some(7_345),
|
|
used_source_pts: true,
|
|
lag_clamped: false,
|
|
},
|
|
256,
|
|
);
|
|
});
|
|
}
|
|
}
|