lesavka/testing/tests/client_camera_include_contract.rs

474 lines
16 KiB
Rust
Raw Normal View History

//! 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}"
);
2026-04-21 20:19:47 -03:00
let (enc, key_prop) = CameraCapture::choose_encoder();
assert!(
matches!(
enc,
"nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"
),
"unexpected encoder: {enc}"
);
2026-04-21 20:19:47 -03:00
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,
);
});
}
}