diff --git a/client/src/input/audio_codec.rs b/client/src/input/audio_codec.rs index afe1187..a5c8783 100644 --- a/client/src/input/audio_codec.rs +++ b/client/src/input/audio_codec.rs @@ -179,206 +179,5 @@ impl Drop for OpusPacketEncoder { } #[cfg(test)] -mod tests { - use super::*; - use lesavka_common::lesavka::AudioEncoding; - - #[test] - fn requested_audio_codec_defaults_to_pcm_and_parses_opus() { - temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || { - temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, None::<&str>, || { - assert_eq!( - requested_upstream_audio_codec_from_env(), - UpstreamAudioCodec::PcmS16le - ); - }); - }); - temp_env::with_var(AUDIO_CODEC_ENV, Some("pcm"), || { - assert_eq!( - requested_upstream_audio_codec_from_env(), - UpstreamAudioCodec::PcmS16le - ); - }); - temp_env::with_var(AUDIO_CODEC_ENV, Some("opus"), || { - temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("pcm"), || { - assert_eq!( - requested_upstream_audio_codec_from_env(), - UpstreamAudioCodec::Opus - ); - }); - }); - temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || { - temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("compressed"), || { - assert_eq!( - requested_upstream_audio_codec_from_env(), - UpstreamAudioCodec::Opus - ); - }); - }); - temp_env::with_var(AUDIO_CODEC_ENV, Some("aac"), || { - temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("opus"), || { - assert_eq!( - requested_upstream_audio_codec_from_env(), - UpstreamAudioCodec::PcmS16le - ); - }); - }); - } - - #[test] - fn opus_encoder_preserves_packet_timing_when_plugin_is_available() { - let _ = gst::init(); - if gst::ElementFactory::find("opusenc").is_none() { - return; - } - let mut packet = AudioPacket { - pts: 123_000, - data: vec![0; AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize], - frame_duration_us: 20_000, - ..AudioPacket::default() - }; - let raw_len = packet.data.len(); - audio_transport::mark_packet_pcm_s16le(&mut packet); - - let mut encoder = OpusPacketEncoder::new().expect("opus encoder"); - let encoded = encoder - .encode_packet(packet.clone()) - .expect("encode") - .unwrap_or_else(|| packet.clone()); - assert_eq!(encoded.pts, 123_000); - assert_eq!(encoded.sample_rate, 48_000); - assert_eq!(encoded.channels, 2); - if encoded.encoding == AudioEncoding::Opus as i32 { - assert!( - encoded.data.len() < raw_len, - "Opus packet should be smaller than raw PCM" - ); - } - } - - #[test] - fn opus_encoder_decodes_non_silent_voice_like_pcm_when_plugin_is_available() { - let _ = gst::init(); - if gst::ElementFactory::find("opusenc").is_none() - || gst::ElementFactory::find("opusdec").is_none() - { - return; - } - let mut packet = AudioPacket { - pts: 456_000, - data: sine_pcm_packet( - 440.0, - AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize, - ), - frame_duration_us: 20_000, - ..AudioPacket::default() - }; - audio_transport::mark_packet_pcm_s16le(&mut packet); - - let mut encoder = OpusPacketEncoder::new().expect("opus encoder"); - let mut encoded = None; - for _ in 0..4 { - encoded = encoder.encode_packet(packet.clone()).expect("encode"); - if encoded.is_some() { - break; - } - } - let Some(encoded) = encoded else { - return; - }; - - let decoded = decode_opus_payload(&encoded.data).expect("decode opus payload"); - let peak = decoded - .chunks_exact(2) - .map(|sample| i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs()) - .max() - .unwrap_or(0); - assert!( - peak > 1_000, - "decoded Opus should contain a real waveform, not silence/garbage" - ); - } - - #[test] - fn packet_metadata_keeps_capture_timing_without_pcm_payload() { - let packet = AudioPacket { - pts: 987_000, - encoding: AudioEncoding::PcmS16le as i32, - sample_rate: 48_000, - channels: 2, - frame_duration_us: 20_000, - data: vec![0x11; 3_840], - ..AudioPacket::default() - }; - - let metadata = packet_metadata(&packet); - - assert_eq!(metadata.pts, 987_000); - assert_eq!(metadata.encoding, AudioEncoding::PcmS16le as i32); - assert_eq!(metadata.sample_rate, 48_000); - assert_eq!(metadata.channels, 2); - assert_eq!(metadata.frame_duration_us, 20_000); - assert!(metadata.data.is_empty()); - } - - #[test] - fn pending_packet_metadata_drops_oldest_when_encoder_lags() { - let mut pending = VecDeque::new(); - - for pts in 100..120 { - push_pending_packet( - &mut pending, - AudioPacket { - pts, - ..AudioPacket::default() - }, - ); - } - - assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA); - assert_eq!(pending.front().expect("oldest retained").pts, 104); - assert_eq!(pending.back().expect("newest retained").pts, 119); - } - - fn sine_pcm_packet(freq_hz: f32, len: usize) -> Vec { - let mut out = Vec::with_capacity(len); - let frames = len / 4; - for frame in 0..frames { - let phase = (frame as f32 * freq_hz * std::f32::consts::TAU) / 48_000.0; - let sample = (phase.sin() * 12_000.0) as i16; - out.extend_from_slice(&sample.to_le_bytes()); - out.extend_from_slice(&sample.to_le_bytes()); - } - out - } - - fn decode_opus_payload(payload: &[u8]) -> Option> { - let desc = "\ - appsrc name=src is-live=true block=false format=time \ - caps=audio/x-opus,channel-mapping-family=0 ! \ - opusdec plc=false use-inband-fec=false min-latency=0 tolerance=0 ! \ - audioconvert ! audioresample ! \ - audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \ - appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true"; - let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?; - let appsrc = pipeline - .by_name("src")? - .downcast::() - .ok()?; - let appsink = pipeline - .by_name("sink")? - .downcast::() - .ok()?; - pipeline.set_state(gst::State::Playing).ok()?; - let mut buffer = gst::Buffer::from_slice(payload.to_vec()); - if let Some(meta) = buffer.get_mut() { - meta.set_pts(Some(gst::ClockTime::from_useconds(456_000))); - meta.set_duration(Some(gst::ClockTime::from_useconds(20_000))); - } - appsrc.push_buffer(buffer).ok()?; - let sample = appsink.try_pull_sample(gst::ClockTime::from_mseconds(100))?; - let decoded = sample.buffer()?.map_readable().ok()?.to_vec(); - let _ = pipeline.set_state(gst::State::Null); - Some(decoded) - } -} +#[path = "audio_codec/tests/mod.rs"] +mod tests; diff --git a/client/src/input/audio_codec/tests/mod.rs b/client/src/input/audio_codec/tests/mod.rs new file mode 100644 index 0000000..e757034 --- /dev/null +++ b/client/src/input/audio_codec/tests/mod.rs @@ -0,0 +1,201 @@ +use super::*; +use lesavka_common::lesavka::AudioEncoding; + +#[test] +fn requested_audio_codec_defaults_to_pcm_and_parses_opus() { + temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || { + temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, None::<&str>, || { + assert_eq!( + requested_upstream_audio_codec_from_env(), + UpstreamAudioCodec::PcmS16le + ); + }); + }); + temp_env::with_var(AUDIO_CODEC_ENV, Some("pcm"), || { + assert_eq!( + requested_upstream_audio_codec_from_env(), + UpstreamAudioCodec::PcmS16le + ); + }); + temp_env::with_var(AUDIO_CODEC_ENV, Some("opus"), || { + temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("pcm"), || { + assert_eq!( + requested_upstream_audio_codec_from_env(), + UpstreamAudioCodec::Opus + ); + }); + }); + temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || { + temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("compressed"), || { + assert_eq!( + requested_upstream_audio_codec_from_env(), + UpstreamAudioCodec::Opus + ); + }); + }); + temp_env::with_var(AUDIO_CODEC_ENV, Some("aac"), || { + temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("opus"), || { + assert_eq!( + requested_upstream_audio_codec_from_env(), + UpstreamAudioCodec::PcmS16le + ); + }); + }); +} + +#[test] +fn opus_encoder_preserves_packet_timing_when_plugin_is_available() { + let _ = gst::init(); + if gst::ElementFactory::find("opusenc").is_none() { + return; + } + let mut packet = AudioPacket { + pts: 123_000, + data: vec![0; AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize], + frame_duration_us: 20_000, + ..AudioPacket::default() + }; + let raw_len = packet.data.len(); + audio_transport::mark_packet_pcm_s16le(&mut packet); + + let mut encoder = OpusPacketEncoder::new().expect("opus encoder"); + let encoded = encoder + .encode_packet(packet.clone()) + .expect("encode") + .unwrap_or_else(|| packet.clone()); + assert_eq!(encoded.pts, 123_000); + assert_eq!(encoded.sample_rate, 48_000); + assert_eq!(encoded.channels, 2); + if encoded.encoding == AudioEncoding::Opus as i32 { + assert!( + encoded.data.len() < raw_len, + "Opus packet should be smaller than raw PCM" + ); + } +} + +#[test] +fn opus_encoder_decodes_non_silent_voice_like_pcm_when_plugin_is_available() { + let _ = gst::init(); + if gst::ElementFactory::find("opusenc").is_none() + || gst::ElementFactory::find("opusdec").is_none() + { + return; + } + let mut packet = AudioPacket { + pts: 456_000, + data: sine_pcm_packet( + 440.0, + AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize, + ), + frame_duration_us: 20_000, + ..AudioPacket::default() + }; + audio_transport::mark_packet_pcm_s16le(&mut packet); + + let mut encoder = OpusPacketEncoder::new().expect("opus encoder"); + let mut encoded = None; + for _ in 0..4 { + encoded = encoder.encode_packet(packet.clone()).expect("encode"); + if encoded.is_some() { + break; + } + } + let Some(encoded) = encoded else { + return; + }; + + let decoded = decode_opus_payload(&encoded.data).expect("decode opus payload"); + let peak = decoded + .chunks_exact(2) + .map(|sample| i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs()) + .max() + .unwrap_or(0); + assert!( + peak > 1_000, + "decoded Opus should contain a real waveform, not silence/garbage" + ); +} + +#[test] +fn packet_metadata_keeps_capture_timing_without_pcm_payload() { + let packet = AudioPacket { + pts: 987_000, + encoding: AudioEncoding::PcmS16le as i32, + sample_rate: 48_000, + channels: 2, + frame_duration_us: 20_000, + data: vec![0x11; 3_840], + ..AudioPacket::default() + }; + + let metadata = packet_metadata(&packet); + + assert_eq!(metadata.pts, 987_000); + assert_eq!(metadata.encoding, AudioEncoding::PcmS16le as i32); + assert_eq!(metadata.sample_rate, 48_000); + assert_eq!(metadata.channels, 2); + assert_eq!(metadata.frame_duration_us, 20_000); + assert!(metadata.data.is_empty()); +} + +#[test] +fn pending_packet_metadata_drops_oldest_when_encoder_lags() { + let mut pending = VecDeque::new(); + + for pts in 100..120 { + push_pending_packet( + &mut pending, + AudioPacket { + pts, + ..AudioPacket::default() + }, + ); + } + + assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA); + assert_eq!(pending.front().expect("oldest retained").pts, 104); + assert_eq!(pending.back().expect("newest retained").pts, 119); +} + +fn sine_pcm_packet(freq_hz: f32, len: usize) -> Vec { + let mut out = Vec::with_capacity(len); + let frames = len / 4; + for frame in 0..frames { + let phase = (frame as f32 * freq_hz * std::f32::consts::TAU) / 48_000.0; + let sample = (phase.sin() * 12_000.0) as i16; + out.extend_from_slice(&sample.to_le_bytes()); + out.extend_from_slice(&sample.to_le_bytes()); + } + out +} + +fn decode_opus_payload(payload: &[u8]) -> Option> { + let desc = "\ + appsrc name=src is-live=true block=false format=time \ + caps=audio/x-opus,channel-mapping-family=0 ! \ + opusdec plc=false use-inband-fec=false min-latency=0 tolerance=0 ! \ + audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true"; + let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?; + let appsrc = pipeline + .by_name("src")? + .downcast::() + .ok()?; + let appsink = pipeline + .by_name("sink")? + .downcast::() + .ok()?; + pipeline.set_state(gst::State::Playing).ok()?; + let mut buffer = gst::Buffer::from_slice(payload.to_vec()); + if let Some(meta) = buffer.get_mut() { + meta.set_pts(Some(gst::ClockTime::from_useconds(456_000))); + meta.set_duration(Some(gst::ClockTime::from_useconds(20_000))); + } + appsrc.push_buffer(buffer).ok()?; + let sample = appsink.try_pull_sample(gst::ClockTime::from_mseconds(100))?; + let decoded = sample.buffer()?.map_readable().ok()?.to_vec(); + let _ = pipeline.set_state(gst::State::Null); + Some(decoded) +} diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index be8bf56..15dee6e 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -68,224 +68,5 @@ include!("camera/preview_tap.rs"); include!("camera/bus_and_encoder.rs"); #[cfg(test)] -mod tests { - use super::{ - CameraCapture, CameraCodec, CameraConfig, hevc_keyframe_interval, resolved_capture_profile, - resolved_output_profile, - }; - use serial_test::serial; - - #[test] - #[serial] - /// Keeps the selected local webcam mode independent from the UVC gadget mode. - fn local_capture_profile_keeps_launcher_quality_env_by_default() { - let cfg = CameraConfig { - codec: CameraCodec::Mjpeg, - width: 640, - height: 480, - fps: 20, - }; - temp_env::with_vars( - [ - ("LESAVKA_CAM_WIDTH", Some("1280")), - ("LESAVKA_CAM_HEIGHT", Some("720")), - ("LESAVKA_CAM_FPS", Some("30")), - ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), - ("LESAVKA_CAM_EMIT_UI_PROFILE", None), - ], - || assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)), - ); - } - - #[test] - #[serial] - /// UVC output must match the gadget profile that browsers negotiate. - fn negotiated_output_profile_matches_server_uvc_contract_by_default() { - let cfg = CameraConfig { - codec: CameraCodec::Mjpeg, - width: 640, - height: 480, - fps: 20, - }; - temp_env::with_vars( - [ - ("LESAVKA_CAM_WIDTH", Some("1280")), - ("LESAVKA_CAM_HEIGHT", Some("720")), - ("LESAVKA_CAM_FPS", Some("30")), - ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), - ("LESAVKA_CAM_EMIT_UI_PROFILE", None), - ], - || { - let capture_profile = resolved_capture_profile(Some(cfg)); - assert_eq!(capture_profile, (1280, 720, 30)); - assert_eq!( - resolved_output_profile(Some(cfg), capture_profile), - (640, 480, 20) - ); - }, - ); - } - - #[test] - #[serial] - /// Keeps UI-profile emission explicit until the server can reconfigure UVC. - fn explicit_ui_profile_emission_keeps_lab_mode_available() { - let cfg = CameraConfig { - codec: CameraCodec::Mjpeg, - width: 640, - height: 480, - fps: 20, - }; - temp_env::with_vars( - [ - ("LESAVKA_CAM_WIDTH", Some("1280")), - ("LESAVKA_CAM_HEIGHT", Some("720")), - ("LESAVKA_CAM_FPS", Some("30")), - ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), - ("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")), - ], - || { - let capture_profile = resolved_capture_profile(Some(cfg)); - assert_eq!(capture_profile, (1280, 720, 30)); - assert_eq!( - resolved_output_profile(Some(cfg), capture_profile), - (1280, 720, 30) - ); - }, - ); - } - - #[test] - #[serial] - /// The safety lock wins if both experimental flags are set. - fn explicit_server_profile_lock_wins_over_ui_emission() { - let cfg = CameraConfig { - codec: CameraCodec::Mjpeg, - width: 640, - height: 480, - fps: 20, - }; - temp_env::with_vars( - [ - ("LESAVKA_CAM_WIDTH", Some("1280")), - ("LESAVKA_CAM_HEIGHT", Some("720")), - ("LESAVKA_CAM_FPS", Some("30")), - ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", Some("1")), - ("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")), - ], - || { - let capture_profile = resolved_capture_profile(Some(cfg)); - assert_eq!(capture_profile, (1280, 720, 30)); - assert_eq!( - resolved_output_profile(Some(cfg), capture_profile), - (640, 480, 20) - ); - }, - ); - } - - #[test] - #[serial] - /// HEVC lab fallback options must stay shaped for live transport. - fn hevc_lab_fallback_options_keep_low_latency_and_keyframes() { - temp_env::with_var("LESAVKA_CAM_HEVC_KBIT", Some("2400"), || { - let options = CameraCapture::encoder_options("x265enc", Some("key-int-max"), 30); - - assert!(options.starts_with("x265enc ")); - assert!(options.contains("tune=zerolatency")); - assert!(options.contains("speed-preset=ultrafast")); - assert!(options.contains("bitrate=2400")); - assert!(options.contains("key-int-max=30")); - }); - } - - #[test] - #[serial] - /// Vulkan H.264 hardware encode should stay live-call shaped when available. - fn vulkan_h264_encoder_options_keep_cbr_and_keyframes() { - temp_env::with_var("LESAVKA_CAM_H264_KBIT", Some("6000"), || { - let options = CameraCapture::encoder_options("vulkanh264enc", Some("idr-period"), 30); - - assert_eq!( - options, - "vulkanh264enc bitrate=6000 rate-control=cbr idr-period=30" - ); - }); - } - - #[test] - #[serial] - /// HEVC should recover quickly after freshness drops without changing H.264 knobs. - fn hevc_keyframe_interval_defaults_short_and_honors_overrides() { - temp_env::with_vars( - [ - ("LESAVKA_CAM_KEYFRAME_INTERVAL", None::<&str>), - ("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", None::<&str>), - ], - || { - assert_eq!(hevc_keyframe_interval(30), 1); - assert_eq!(hevc_keyframe_interval(2), 1); - }, - ); - temp_env::with_vars( - [ - ("LESAVKA_CAM_KEYFRAME_INTERVAL", Some("5")), - ("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", Some("2")), - ], - || { - assert_eq!(hevc_keyframe_interval(30), 2); - }, - ); - } - - #[cfg(coverage)] - #[test] - /// Coverage builds use a deterministic HEVC encoder choice. - fn coverage_hevc_encoder_choice_is_stable() { - assert_eq!( - CameraCapture::choose_hevc_encoder().unwrap(), - ("x265enc", Some("key-int-max")) - ); - } - - #[cfg(coverage)] - #[test] - #[serial] - /// Coverage builds can exercise H.264 encoder branches without host GPU assumptions. - fn coverage_h264_encoder_choice_honors_stable_test_overrides() { - let cases = [ - ("nvh264enc", ("nvh264enc", None)), - ("vulkanh264enc", ("vulkanh264enc", Some("idr-period"))), - ("vaapih264enc", ("vaapih264enc", Some("keyframe-period"))), - ("v4l2h264enc", ("v4l2h264enc", Some("idrcount"))), - ("unknown", ("x264enc", Some("key-int-max"))), - ]; - - for (override_value, expected) in cases { - temp_env::with_var("LESAVKA_CAM_TEST_ENCODER", Some(override_value), || { - assert_eq!(CameraCapture::choose_encoder().unwrap(), expected); - }); - } - } - - #[cfg(coverage)] - #[test] - /// Coverage mode keeps software video fallback enabled for deterministic tests. - fn coverage_software_video_fallback_is_enabled() { - assert!(CameraCapture::software_video_fallback_allowed()); - } - - #[cfg(coverage)] - #[test] - /// Coverage mode replaces the FFmpeg preview reader with an inert cancellation flag. - fn coverage_ffmpeg_preview_tap_stub_starts_stopped() { - let running = super::spawn_ffmpeg_raw_preview_tap( - std::io::empty(), - std::path::PathBuf::from("/tmp/lesavka-preview-tap.coverage"), - 1, - 1, - ); - - assert!(!running.load(std::sync::atomic::Ordering::Acquire)); - } -} +#[path = "camera/tests/mod.rs"] +mod tests; diff --git a/client/src/input/camera/tests/mod.rs b/client/src/input/camera/tests/mod.rs new file mode 100644 index 0000000..748ab15 --- /dev/null +++ b/client/src/input/camera/tests/mod.rs @@ -0,0 +1,219 @@ +use super::{ + CameraCapture, CameraCodec, CameraConfig, hevc_keyframe_interval, resolved_capture_profile, + resolved_output_profile, +}; +use serial_test::serial; + +#[test] +#[serial] +/// Keeps the selected local webcam mode independent from the UVC gadget mode. +fn local_capture_profile_keeps_launcher_quality_env_by_default() { + let cfg = CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }; + temp_env::with_vars( + [ + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), + ("LESAVKA_CAM_EMIT_UI_PROFILE", None), + ], + || assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)), + ); +} + +#[test] +#[serial] +/// UVC output must match the gadget profile that browsers negotiate. +fn negotiated_output_profile_matches_server_uvc_contract_by_default() { + let cfg = CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }; + temp_env::with_vars( + [ + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), + ("LESAVKA_CAM_EMIT_UI_PROFILE", None), + ], + || { + let capture_profile = resolved_capture_profile(Some(cfg)); + assert_eq!(capture_profile, (1280, 720, 30)); + assert_eq!( + resolved_output_profile(Some(cfg), capture_profile), + (640, 480, 20) + ); + }, + ); +} + +#[test] +#[serial] +/// Keeps UI-profile emission explicit until the server can reconfigure UVC. +fn explicit_ui_profile_emission_keeps_lab_mode_available() { + let cfg = CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }; + temp_env::with_vars( + [ + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None), + ("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")), + ], + || { + let capture_profile = resolved_capture_profile(Some(cfg)); + assert_eq!(capture_profile, (1280, 720, 30)); + assert_eq!( + resolved_output_profile(Some(cfg), capture_profile), + (1280, 720, 30) + ); + }, + ); +} + +#[test] +#[serial] +/// The safety lock wins if both experimental flags are set. +fn explicit_server_profile_lock_wins_over_ui_emission() { + let cfg = CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }; + temp_env::with_vars( + [ + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", Some("1")), + ("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")), + ], + || { + let capture_profile = resolved_capture_profile(Some(cfg)); + assert_eq!(capture_profile, (1280, 720, 30)); + assert_eq!( + resolved_output_profile(Some(cfg), capture_profile), + (640, 480, 20) + ); + }, + ); +} + +#[test] +#[serial] +/// HEVC lab fallback options must stay shaped for live transport. +fn hevc_lab_fallback_options_keep_low_latency_and_keyframes() { + temp_env::with_var("LESAVKA_CAM_HEVC_KBIT", Some("2400"), || { + let options = CameraCapture::encoder_options("x265enc", Some("key-int-max"), 30); + + assert!(options.starts_with("x265enc ")); + assert!(options.contains("tune=zerolatency")); + assert!(options.contains("speed-preset=ultrafast")); + assert!(options.contains("bitrate=2400")); + assert!(options.contains("key-int-max=30")); + }); +} + +#[test] +#[serial] +/// Vulkan H.264 hardware encode should stay live-call shaped when available. +fn vulkan_h264_encoder_options_keep_cbr_and_keyframes() { + temp_env::with_var("LESAVKA_CAM_H264_KBIT", Some("6000"), || { + let options = CameraCapture::encoder_options("vulkanh264enc", Some("idr-period"), 30); + + assert_eq!( + options, + "vulkanh264enc bitrate=6000 rate-control=cbr idr-period=30" + ); + }); +} + +#[test] +#[serial] +/// HEVC should recover quickly after freshness drops without changing H.264 knobs. +fn hevc_keyframe_interval_defaults_short_and_honors_overrides() { + temp_env::with_vars( + [ + ("LESAVKA_CAM_KEYFRAME_INTERVAL", None::<&str>), + ("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", None::<&str>), + ], + || { + assert_eq!(hevc_keyframe_interval(30), 1); + assert_eq!(hevc_keyframe_interval(2), 1); + }, + ); + temp_env::with_vars( + [ + ("LESAVKA_CAM_KEYFRAME_INTERVAL", Some("5")), + ("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", Some("2")), + ], + || { + assert_eq!(hevc_keyframe_interval(30), 2); + }, + ); +} + +#[cfg(coverage)] +#[test] +/// Coverage builds use a deterministic HEVC encoder choice. +fn coverage_hevc_encoder_choice_is_stable() { + assert_eq!( + CameraCapture::choose_hevc_encoder().unwrap(), + ("x265enc", Some("key-int-max")) + ); +} + +#[cfg(coverage)] +#[test] +#[serial] +/// Coverage builds can exercise H.264 encoder branches without host GPU assumptions. +fn coverage_h264_encoder_choice_honors_stable_test_overrides() { + let cases = [ + ("nvh264enc", ("nvh264enc", None)), + ("vulkanh264enc", ("vulkanh264enc", Some("idr-period"))), + ("vaapih264enc", ("vaapih264enc", Some("keyframe-period"))), + ("v4l2h264enc", ("v4l2h264enc", Some("idrcount"))), + ("unknown", ("x264enc", Some("key-int-max"))), + ]; + + for (override_value, expected) in cases { + temp_env::with_var("LESAVKA_CAM_TEST_ENCODER", Some(override_value), || { + assert_eq!(CameraCapture::choose_encoder().unwrap(), expected); + }); + } +} + +#[cfg(coverage)] +#[test] +/// Coverage mode keeps software video fallback enabled for deterministic tests. +fn coverage_software_video_fallback_is_enabled() { + assert!(CameraCapture::software_video_fallback_allowed()); +} + +#[cfg(coverage)] +#[test] +/// Coverage mode replaces the FFmpeg preview reader with an inert cancellation flag. +fn coverage_ffmpeg_preview_tap_stub_starts_stopped() { + let running = super::spawn_ffmpeg_raw_preview_tap( + std::io::empty(), + std::path::PathBuf::from("/tmp/lesavka-preview-tap.coverage"), + 1, + 1, + ); + + assert!(!running.load(std::sync::atomic::Ordering::Acquire)); +} diff --git a/client/src/live_media_control.rs b/client/src/live_media_control.rs index 966ebdb..752820d 100644 --- a/client/src/live_media_control.rs +++ b/client/src/live_media_control.rs @@ -442,5 +442,5 @@ fn control_request_nonce() -> u128 { } #[cfg(test)] -#[path = "live_media_control/tests.rs"] +#[path = "live_media_control/tests/mod.rs"] mod tests; diff --git a/client/src/live_media_control/tests.rs b/client/src/live_media_control/tests/mod.rs similarity index 100% rename from client/src/live_media_control/tests.rs rename to client/src/live_media_control/tests/mod.rs diff --git a/server/src/audio/opus_decode.rs b/server/src/audio/opus_decode.rs index 30a6e3a..3cb66ba 100644 --- a/server/src/audio/opus_decode.rs +++ b/server/src/audio/opus_decode.rs @@ -147,143 +147,5 @@ impl Drop for OpusPacketDecoder { } #[cfg(test)] -mod tests { - use super::*; - use lesavka_common::lesavka::AudioEncoding; - - #[test] - fn opus_decoder_roundtrips_to_pcm_when_plugins_are_available() { - let _ = gst::init(); - if gst::ElementFactory::find("opusenc").is_none() - || gst::ElementFactory::find("opusdec").is_none() - { - return; - } - - let Some(opus_payload) = encode_silent_opus_payload() else { - return; - }; - let packet = AudioPacket { - pts: 42_000, - encoding: AudioEncoding::Opus as i32, - sample_rate: 48_000, - channels: 2, - frame_duration_us: 20_000, - data: opus_payload, - ..AudioPacket::default() - }; - - let mut decoder = OpusPacketDecoder::new().expect("opus decoder"); - let decoded = decoder - .decode_packet(&packet) - .expect("decode") - .expect("decoded pcm"); - - assert_eq!(decoded.pts, 42_000); - assert_eq!(decoded.encoding, AudioEncoding::PcmS16le as i32); - assert_eq!(decoded.sample_rate, 48_000); - assert_eq!(decoded.channels, 2); - assert!( - decoded.data.len() >= 1_000, - "decoded PCM should be far larger than one compressed Opus frame" - ); - assert!( - decoded.data.chunks_exact(2).any(|sample| { - i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs() > 250 - }), - "decoded Opus payload should preserve non-silent waveform energy" - ); - } - - #[test] - fn packet_metadata_keeps_timing_and_clears_payload() { - let packet = AudioPacket { - pts: 123_000, - encoding: AudioEncoding::Opus as i32, - sample_rate: 48_000, - channels: 2, - frame_duration_us: 20_000, - data: vec![1, 2, 3, 4], - ..AudioPacket::default() - }; - - let metadata = packet_metadata(&packet); - - assert_eq!(metadata.pts, 123_000); - assert_eq!(metadata.encoding, AudioEncoding::Opus as i32); - assert_eq!(metadata.sample_rate, 48_000); - assert_eq!(metadata.channels, 2); - assert_eq!(metadata.frame_duration_us, 20_000); - assert!(metadata.data.is_empty()); - } - - #[test] - fn pending_packet_metadata_is_bounded_to_recent_frames() { - let mut pending = VecDeque::new(); - - for pts in 0..20 { - push_pending_packet( - &mut pending, - AudioPacket { - pts, - ..AudioPacket::default() - }, - ); - } - - assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA); - assert_eq!(pending.front().expect("oldest retained").pts, 4); - assert_eq!(pending.back().expect("newest retained").pts, 19); - } - - fn encode_silent_opus_payload() -> Option> { - let desc = "\ - appsrc name=src is-live=true block=false format=time \ - caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \ - opusenc audio-type=restricted-lowdelay bitrate=96000 bitrate-type=cbr complexity=7 frame-size=20 perfect-timestamp=true hard-resync=true ! \ - appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true"; - let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?; - let appsrc = pipeline - .by_name("src")? - .downcast::() - .ok()?; - let appsink = pipeline - .by_name("sink")? - .downcast::() - .ok()?; - pipeline.set_state(gst::State::Playing).ok()?; - - for index in 0..4u64 { - let mut buffer = gst::Buffer::from_slice(sine_pcm_packet(index, 3_840)); - if let Some(meta) = buffer.get_mut() { - let pts = gst::ClockTime::from_useconds(index * 20_000); - meta.set_pts(Some(pts)); - meta.set_dts(Some(pts)); - meta.set_duration(Some(gst::ClockTime::from_useconds(20_000))); - } - appsrc.push_buffer(buffer).ok()?; - if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(50)) { - let payload = sample.buffer()?.map_readable().ok()?.to_vec(); - let _ = pipeline.set_state(gst::State::Null); - if !payload.is_empty() { - return Some(payload); - } - } - } - let _ = pipeline.set_state(gst::State::Null); - None - } - - fn sine_pcm_packet(packet_index: u64, len: usize) -> Vec { - let mut out = Vec::with_capacity(len); - let frames = len / 4; - for frame in 0..frames { - let absolute = packet_index as usize * frames + frame; - let phase = (absolute as f32 * 440.0 * std::f32::consts::TAU) / 48_000.0; - let sample = (phase.sin() * 12_000.0) as i16; - out.extend_from_slice(&sample.to_le_bytes()); - out.extend_from_slice(&sample.to_le_bytes()); - } - out - } -} +#[path = "opus_decode/tests/mod.rs"] +mod tests; diff --git a/server/src/audio/opus_decode/tests/mod.rs b/server/src/audio/opus_decode/tests/mod.rs new file mode 100644 index 0000000..213a175 --- /dev/null +++ b/server/src/audio/opus_decode/tests/mod.rs @@ -0,0 +1,138 @@ +use super::*; +use lesavka_common::lesavka::AudioEncoding; + +#[test] +fn opus_decoder_roundtrips_to_pcm_when_plugins_are_available() { + let _ = gst::init(); + if gst::ElementFactory::find("opusenc").is_none() + || gst::ElementFactory::find("opusdec").is_none() + { + return; + } + + let Some(opus_payload) = encode_silent_opus_payload() else { + return; + }; + let packet = AudioPacket { + pts: 42_000, + encoding: AudioEncoding::Opus as i32, + sample_rate: 48_000, + channels: 2, + frame_duration_us: 20_000, + data: opus_payload, + ..AudioPacket::default() + }; + + let mut decoder = OpusPacketDecoder::new().expect("opus decoder"); + let decoded = decoder + .decode_packet(&packet) + .expect("decode") + .expect("decoded pcm"); + + assert_eq!(decoded.pts, 42_000); + assert_eq!(decoded.encoding, AudioEncoding::PcmS16le as i32); + assert_eq!(decoded.sample_rate, 48_000); + assert_eq!(decoded.channels, 2); + assert!( + decoded.data.len() >= 1_000, + "decoded PCM should be far larger than one compressed Opus frame" + ); + assert!( + decoded.data.chunks_exact(2).any(|sample| { + i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs() > 250 + }), + "decoded Opus payload should preserve non-silent waveform energy" + ); +} + +#[test] +fn packet_metadata_keeps_timing_and_clears_payload() { + let packet = AudioPacket { + pts: 123_000, + encoding: AudioEncoding::Opus as i32, + sample_rate: 48_000, + channels: 2, + frame_duration_us: 20_000, + data: vec![1, 2, 3, 4], + ..AudioPacket::default() + }; + + let metadata = packet_metadata(&packet); + + assert_eq!(metadata.pts, 123_000); + assert_eq!(metadata.encoding, AudioEncoding::Opus as i32); + assert_eq!(metadata.sample_rate, 48_000); + assert_eq!(metadata.channels, 2); + assert_eq!(metadata.frame_duration_us, 20_000); + assert!(metadata.data.is_empty()); +} + +#[test] +fn pending_packet_metadata_is_bounded_to_recent_frames() { + let mut pending = VecDeque::new(); + + for pts in 0..20 { + push_pending_packet( + &mut pending, + AudioPacket { + pts, + ..AudioPacket::default() + }, + ); + } + + assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA); + assert_eq!(pending.front().expect("oldest retained").pts, 4); + assert_eq!(pending.back().expect("newest retained").pts, 19); +} + +fn encode_silent_opus_payload() -> Option> { + let desc = "\ + appsrc name=src is-live=true block=false format=time \ + caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \ + opusenc audio-type=restricted-lowdelay bitrate=96000 bitrate-type=cbr complexity=7 frame-size=20 perfect-timestamp=true hard-resync=true ! \ + appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true"; + let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?; + let appsrc = pipeline + .by_name("src")? + .downcast::() + .ok()?; + let appsink = pipeline + .by_name("sink")? + .downcast::() + .ok()?; + pipeline.set_state(gst::State::Playing).ok()?; + + for index in 0..4u64 { + let mut buffer = gst::Buffer::from_slice(sine_pcm_packet(index, 3_840)); + if let Some(meta) = buffer.get_mut() { + let pts = gst::ClockTime::from_useconds(index * 20_000); + meta.set_pts(Some(pts)); + meta.set_dts(Some(pts)); + meta.set_duration(Some(gst::ClockTime::from_useconds(20_000))); + } + appsrc.push_buffer(buffer).ok()?; + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(50)) { + let payload = sample.buffer()?.map_readable().ok()?.to_vec(); + let _ = pipeline.set_state(gst::State::Null); + if !payload.is_empty() { + return Some(payload); + } + } + } + let _ = pipeline.set_state(gst::State::Null); + None +} + +fn sine_pcm_packet(packet_index: u64, len: usize) -> Vec { + let mut out = Vec::with_capacity(len); + let frames = len / 4; + for frame in 0..frames { + let absolute = packet_index as usize * frames + frame; + let phase = (absolute as f32 * 440.0 * std::f32::consts::TAU) / 48_000.0; + let sample = (phase.sin() * 12_000.0) as i16; + out.extend_from_slice(&sample.to_le_bytes()); + out.extend_from_slice(&sample.to_le_bytes()); + } + out +} diff --git a/server/src/bin/lesavka-synthetic-uplink.rs b/server/src/bin/lesavka-synthetic-uplink.rs index 308d555..a1a1675 100755 --- a/server/src/bin/lesavka-synthetic-uplink.rs +++ b/server/src/bin/lesavka-synthetic-uplink.rs @@ -444,5 +444,5 @@ async fn main() -> Result<()> { include!("lesavka_synthetic_uplink/support.rs"); #[cfg(test)] -#[path = "lesavka_synthetic_uplink/tests.rs"] +#[path = "lesavka_synthetic_uplink/tests/mod.rs"] mod tests; diff --git a/server/src/bin/lesavka_synthetic_uplink/tests.rs b/server/src/bin/lesavka_synthetic_uplink/tests/mod.rs similarity index 100% rename from server/src/bin/lesavka_synthetic_uplink/tests.rs rename to server/src/bin/lesavka_synthetic_uplink/tests/mod.rs diff --git a/server/src/video_support.rs b/server/src/video_support.rs index 97679f3..6d0d891 100644 --- a/server/src/video_support.rs +++ b/server/src/video_support.rs @@ -338,5 +338,5 @@ pub fn reserve_local_pts(counter: &AtomicU64, preferred_pts_us: u64, frame_step_ } #[cfg(test)] -#[path = "video_support/tests.rs"] +#[path = "video_support/tests/mod.rs"] mod tests; diff --git a/server/src/video_support/tests.rs b/server/src/video_support/tests/mod.rs similarity index 100% rename from server/src/video_support/tests.rs rename to server/src/video_support/tests/mod.rs