diff --git a/crates/oak-codec/src/decoder.rs b/crates/oak-codec/src/decoder.rs index a29e27187..79dadabf2 100644 --- a/crates/oak-codec/src/decoder.rs +++ b/crates/oak-codec/src/decoder.rs @@ -460,13 +460,30 @@ pub fn create_from_id(id: &str) -> Option> { /// not injected, in which case the built-in list below is used. static TEST_DECODERS: OnceLock>>> = OnceLock::new(); -/// Serializes every test that reads the built-in decoder registry. Tests -/// inject through [`set_test_decoders`] under [`crate::lock_tests`] (the -/// shared test lock), so the registry assertions below take that same lock -/// to never race with an injected list. +/// Serializes every test that reads the built-in decoder registry and +/// clears the test injection on drop: a panicking assertion must not leak +/// fake decoders into the process-wide registry used by later tests. The +/// held [`crate::TestLock`] is released only after the clear. #[cfg(test)] -fn registry_guard() -> crate::TestLock { - crate::lock_tests() +struct RegistryGuard { + /// Held for the test's duration; never read, only dropped. + _lock: crate::TestLock, +} + +#[cfg(test)] +impl Drop for RegistryGuard { + fn drop(&mut self) { + set_test_decoders(Vec::new()); + } +} + +/// Take the shared test lock and clear any injection left behind by an +/// earlier panicking test, then return the guard. +#[cfg(test)] +fn registry_guard() -> RegistryGuard { + let lock = crate::lock_tests(); + set_test_decoders(Vec::new()); + RegistryGuard { _lock: lock } } /// Replace the decoder registry with `list`; pass an empty list to restore @@ -785,6 +802,149 @@ mod tests_unimplemented { assert_eq!(off.numerator(), 0); assert_eq!(off.denominator(), 1); } + + /// A decoder that relies on every trait default (no overrides), so the + /// conservative default bodies are covered. + struct DefaultsOnly; + + impl Decoder for DefaultsOnly { + fn id(&self) -> String { + "defaults".to_string() + } + fn probe(&self, _f: &str, _c: Option<&CancelAtom>) -> Option { + None + } + fn open(&self, _s: &CodecStream) -> crate::error::Result<()> { + Err(crate::error::Error::Invalid) + } + fn close(&self) -> crate::error::Result<()> { + Ok(()) + } + fn stream(&self) -> CodecStream { + CodecStream::new() + } + fn retrieve_video_frame( + &self, + _p: &RetrieveVideoParams, + ) -> crate::error::Result> { + Err(crate::error::Error::Invalid) + } + fn retrieve_video( + &self, + _p: &RetrieveVideoParams, + ) -> crate::error::Result { + Err(crate::error::Error::Invalid) + } + fn retrieve_audio( + &self, + _d: &mut [f32], + _r: &TimeRange, + _s: i32, + _l: u64, + ) -> crate::error::Result { + Ok(RetrieveAudioStatus::Unsupported) + } + fn conform_audio( + &self, + _o: &[String], + _s: i32, + _l: u64, + _sf: i32, + _c: Option<&CancelAtom>, + ) -> crate::error::Result<()> { + Ok(()) + } + } + + fn test_params() -> RetrieveVideoParams { + RetrieveVideoParams { + stream: CodecStream::new(), + time: Rational::new(0, 1), + length: TimeRange::default(), + force_range: K_COLOR_RANGE_DEFAULT, + is_image_sequence: false, + image_sequence_digits: 0, + image_sequence_number: 0, + mode: RenderMode::Offline, + alpha_is_premultiplied: false, + target_size: None, + } + } + + #[test] + fn decoder_trait_defaults_are_conservative() { + let d = DefaultsOnly; + assert!(!d.supports_video()); + assert!(!d.supports_audio()); + assert!(!d.hardware_decoding()); + assert_eq!(d.get_audio_start_offset(), Rational::new(0, 1)); + assert!(d.retrieve_video_frame_gpu(&test_params()).unwrap().is_none()); + } + + #[test] + fn oiio_placeholder_reports_unimplemented() { + let _g = registry_guard(); + let d = builtin("oiio"); + assert!(d.probe("x.exr", None).is_none()); + assert!(d + .open(&CodecStream::with_block("x.exr".to_string(), 0, None)) + .is_err()); + assert!(d.close().is_err()); + assert_eq!(d.stream().filename(), ""); + let p = test_params(); + assert!(d.retrieve_video_frame(&p).is_err()); + assert!(d.retrieve_video(&p).is_err()); + let mut dest = [0f32; 4]; + assert!(d + .retrieve_audio( + &mut dest, + &TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + 48000, + 0x3 + ) + .is_err()); + assert!(d + .conform_audio(&["a.pcm".to_string()], 48000, 0x3, 10, None) + .is_err()); + // The placeholder keeps the conservative defaults too. + assert!(!d.hardware_decoding()); + assert_eq!(d.get_audio_start_offset(), Rational::new(0, 1)); + assert!(d.retrieve_video_frame_gpu(&p).unwrap().is_none()); + } + + #[test] + fn decoder_registry_injection_wins_and_restores() { + let _g = registry_guard(); + set_test_decoders(vec![Arc::new(DefaultsOnly)]); + assert_eq!( + create_from_id("defaults").map(|d| d.id()).as_deref(), + Some("defaults") + ); + assert!(create_from_id("ffmpeg").is_none()); + set_test_decoders(Vec::new()); + assert!(create_from_id("ffmpeg").is_some()); + } + + /// The injection restore is panic-safe: the guard clears the registry + /// on unwind, so a failing assertion cannot leave the fake decoder + /// installed for later tests in the same process. + #[test] + fn decoder_registry_clears_after_a_panicking_injection() { + let panicked = std::panic::catch_unwind(|| { + let _g = registry_guard(); + set_test_decoders(vec![Arc::new(DefaultsOnly)]); + assert!( + create_from_id("defaults").is_some(), + "the fake decoder is installed" + ); + panic!("simulated assertion failure after injection"); + }); + assert!(panicked.is_err(), "the closure must panic"); + let _g = registry_guard(); + assert!( + create_from_id("ffmpeg").is_some(), + "no fake decoder may leak into the next test" + ); + assert!(create_from_id("defaults").is_none()); + } } -+ /// Held for the test's duration; never read, only dropped. -+ set_test_decoders(Vec::new()); diff --git a/crates/oak-codec/src/encoder.rs b/crates/oak-codec/src/encoder.rs index a010d021f..14cd73990 100644 --- a/crates/oak-codec/src/encoder.rs +++ b/crates/oak-codec/src/encoder.rs @@ -121,6 +121,31 @@ pub fn set_test_encoders(list: Vec>) { *store.lock().unwrap() = list; } +/// Serializes every test that reads the built-in encoder registry and +/// clears the test injection on drop: a panicking assertion must not leak +/// fake encoders into the process-wide registry used by later tests. The +/// held [`crate::TestLock`] is released only after the clear. +#[cfg(test)] +struct RegistryGuard { + /// Held for the test's duration; never read, only dropped. + _lock: crate::TestLock, +} + +#[cfg(test)] +impl Drop for RegistryGuard { + fn drop(&mut self) { + set_test_encoders(Vec::new()); + } +} + +/// Take the shared test lock and return the guard. +#[cfg(test)] +fn registry_guard() -> RegistryGuard { + RegistryGuard { + _lock: crate::lock_tests(), + } +} + /// `Encoder::create_from_params` — instantiate an encoder for `params`. /// /// # CPP-PARITY @@ -251,6 +276,7 @@ mod tests { #[test] fn create_from_params_maps_formats() { + let _guard = registry_guard(); let mut p = EncodingParams::default(); // FFmpeg-backed containers. @@ -346,7 +372,63 @@ mod tests { String::new() } } + + #[test] + fn encoder_trait_defaults_are_conservative() { + let e = UnimplementedDummy; + assert!(!e.supports_video()); + assert!(!e.supports_audio()); + assert!(!e.supports_subtitles()); + assert!(!e.supports_image_sequences()); + assert!(!e.is_configurable()); + } + + #[test] + fn encoder_registry_injection_wins_and_restores() { + let _guard = registry_guard(); + // Unknown format: the built-in mapping returns None. + let p = EncodingParams { + format: 999, + ..EncodingParams::default() + }; + assert!(create_from_params(&p).is_none()); + + set_test_encoders(vec![Arc::new(UnimplementedDummy)]); + let injected = create_from_params(&p).expect("injected encoder wins"); + assert_eq!(injected.id(), "dummy"); + + set_test_encoders(Vec::new()); + assert!(create_from_params(&p).is_none()); + } + + /// The injection restore is panic-safe: the guard clears the registry + /// on unwind, so a failing assertion cannot leave the fake encoder + /// installed for later tests in the same process. + #[test] + fn encoder_registry_clears_after_a_panicking_injection() { + let panicked = std::panic::catch_unwind(|| { + let _guard = registry_guard(); + set_test_encoders(vec![Arc::new(UnimplementedDummy)]); + let p = EncodingParams { + format: 999, + ..EncodingParams::default() + }; + assert_eq!( + create_from_params(&p).map(|e| e.id()).as_deref(), + Some("dummy"), + "the fake encoder is installed" + ); + panic!("simulated assertion failure after injection"); + }); + assert!(panicked.is_err(), "the closure must panic"); + let _guard = registry_guard(); + let p = EncodingParams { + format: 999, + ..EncodingParams::default() + }; + assert!( + create_from_params(&p).is_none(), + "no fake encoder may leak into the next test" + ); + } } -+ /// Held for the test's duration; never read, only dropped. -+ set_test_encoders(Vec::new()); -+ _lock: crate::lock_tests(), diff --git a/crates/oak-codec/src/encodingparams.rs b/crates/oak-codec/src/encodingparams.rs index f8c0f54fe..64a1f1c01 100644 --- a/crates/oak-codec/src/encodingparams.rs +++ b/crates/oak-codec/src/encodingparams.rs @@ -1118,4 +1118,65 @@ mod tests { assert_eq!(offset_of!(EncodingParams, color_range), 1548); } } - assert_eq!(offset_of!(EncodingParams, color_range), 1548); + +#[cfg(test)] +mod tests_coverage { + use super::*; + + #[test] + fn load_rejects_malformed_and_foreign_documents() { + let mut p = EncodingParams::default(); + for doc in [ + "", + "", + "", + "", + "", + "", + ] { + assert!(p.load(doc).is_err(), "must reject: {doc}"); + } + } + + #[test] + fn load_maps_video_audio_and_falls_back_on_unknown_codes() { + let mut p = EncodingParams::default(); + p.load( + r#"out.mov2 + + + "#, + ) + .unwrap(); + assert_eq!(p.extension(), "mp4", "format 2 is MPEG-4 video"); + assert_eq!(p.video_width, 1920); + assert_eq!(p.video_height, 1080); + assert_eq!(p.video_pixel_format, PixelFormat::F32); + assert_eq!(p.video_time_base_num, 30000); + assert_eq!(p.video_time_base_den, 1001); + assert_eq!(p.video_scaling_method, VideoScalingMethod::Crop); + assert_eq!(p.audio_sample_format, SampleFormat::F64Planar); + assert_eq!(p.audio_bit_rate, 192000); + + // Unknown numeric codes fall back to Invalid / Stretch. + let mut p = EncodingParams::default(); + p.load( + r#" + "#, + ) + .unwrap(); + assert_eq!(p.video_pixel_format, PixelFormat::Invalid); + assert_eq!(p.video_scaling_method, VideoScalingMethod::Stretch); + assert_eq!(p.audio_sample_format, SampleFormat::Invalid); + + // -1 is the explicit Invalid code; vscale 0 is Fit. + let mut p = EncodingParams::default(); + p.load(r#""#) + .unwrap(); + assert_eq!(p.video_pixel_format, PixelFormat::Invalid); + assert_eq!(p.video_scaling_method, VideoScalingMethod::Fit); + } +} diff --git a/crates/oak-codec/src/ffmpeg.rs b/crates/oak-codec/src/ffmpeg.rs index 3d43a5b22..d72401977 100644 --- a/crates/oak-codec/src/ffmpeg.rs +++ b/crates/oak-codec/src/ffmpeg.rs @@ -3405,4 +3405,1254 @@ mod tests { assert_eq!(white[3], 1.0); assert_eq!(black[3], 1.0); } + + // ---- helpers and decoder state (M5 audit batch) ---------------------- + + /// `demo.mp4` at the repository root, opened as `stream`. + fn demo_stream(stream: i32) -> CodecStream { + CodecStream::with_block( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../oak-app/tests/demo.mp4") + .to_string_lossy() + .into_owned(), + stream, + None, + ) + } + + #[test] + fn jpeg_space_conversion_maps_every_variant_and_passes_others_through() { + for (jpeg, regular) in [ + (Pixel::YUVJ420P, Pixel::YUV420P), + (Pixel::YUVJ422P, Pixel::YUV422P), + (Pixel::YUVJ444P, Pixel::YUV444P), + (Pixel::YUVJ440P, Pixel::YUV440P), + (Pixel::YUVJ411P, Pixel::YUV411P), + ] { + assert_eq!(convert_jpeg_space_to_regular_space(jpeg), regular); + } + assert_eq!( + convert_jpeg_space_to_regular_space(Pixel::YUV420P), + Pixel::YUV420P + ); + assert_eq!( + convert_jpeg_space_to_regular_space(Pixel::VAAPI), + Pixel::VAAPI + ); + } + + #[test] + fn native_pixel_format_classifies_depth() { + assert_eq!(native_pixel_format(Pixel::RGBAF32LE), PixelFormat::F32); + assert_eq!(native_pixel_format(Pixel::GBRPF32LE), PixelFormat::F32); + assert_eq!(native_pixel_format(Pixel::RGB48LE), PixelFormat::U16); + assert_eq!(native_pixel_format(Pixel::GBRP10LE), PixelFormat::U16); + assert_eq!(native_pixel_format(Pixel::GBRP16BE), PixelFormat::U16); + assert_eq!(native_pixel_format(Pixel::YUV420P), PixelFormat::U8); + assert_eq!(native_pixel_format(Pixel::RGBA), PixelFormat::U8); + } + + #[test] + fn channel_layout_from_mask_zero_and_multichannel() { + let stereo = channel_layout_from_mask(0); + assert_eq!(stereo.channels(), 2, "zero mask falls back to stereo"); + let surround = channel_layout_from_mask(0b111); + assert_eq!(surround.channels(), 3); + assert_eq!(surround.bits(), 0b111); + } + + #[test] + fn error_and_cancel_helpers_are_informative() { + let err = ffmpeg_err(FfmpegError::Eof); + assert!(format!("{err:?}").contains("ffmpeg error")); + assert!(is_eof_or_eagain(&FfmpegError::Eof)); + assert!(is_eof_or_eagain(&FfmpegError::Other { + errno: ffmpeg::error::EAGAIN + })); + assert!(!is_eof_or_eagain(&FfmpegError::InvalidData)); + + assert!(!cancel_atom_is_cancelled(None)); + let atom = CancelAtom::new(); + assert!(!cancel_atom_is_cancelled(Some(&atom))); + atom.cancel(); + assert!(cancel_atom_is_cancelled(Some(&atom))); + } + + #[test] + fn ref_frame_shares_buffers_and_reports_geometry() { + let mut video = ffmpeg::frame::Video::new(Pixel::YUV420P, 8, 4); + video.set_pts(Some(42)); + let reference = RefFrame::from_video(&video).expect("reference"); + assert_eq!(reference.width(), 8); + assert_eq!(reference.height(), 4); + assert_eq!(reference.pts(), Some(42)); + assert!(!format!("{reference:?}").is_empty()); + + // A clone keeps the buffers alive after the original reference drops. + let clone = reference.clone(); + drop(reference); + let back = clone.to_video().expect("owned video"); + assert_eq!((back.width(), back.height()), (8, 4)); + assert_eq!(back.pts(), Some(42)); + + // `clone_raw` adopts a raw frame with the same semantics. + let raw = RefFrame::clone_raw(unsafe { video.as_ptr() }).expect("clone_raw"); + assert_eq!(raw.width(), 8); + assert_eq!(raw.height(), 4); + } + + #[test] + fn decoder_open_state_machine_and_hardware_report() { + let d = FFmpegDecoder::new(); + // Unopened sessions report state instead of panicking. + assert!(d.retrieve_video_frame_gpu(&video_params()).is_err()); + assert!(!d.hardware_decoding()); + + // A stream without a filename is rejected before any IO. + assert!(d.open(&CodecStream::new()).is_err()); + + // Real media: the same stream re-opens as a no-op, a different one + // is rejected, and close is idempotent. + let video = demo_stream(0); + d.open(&video).expect("open video"); + let _ = d.hardware_decoding(); + d.open(&video).expect("same stream re-open"); + let audio = demo_stream(1); + assert!(matches!( + d.open(&audio), + Err(crate::error::Error::State) + )); + assert!(d.close().is_ok()); + assert!(d.close().is_ok()); + } + + #[test] + fn cross_stream_retrieval_reports_unsupported() { + let d = FFmpegDecoder::new(); + let mut dest = [0f32; 8]; + let range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 10)); + + // A video session has no audio to retrieve. + let video = demo_stream(0); + d.open(&video).expect("open video"); + assert_eq!( + d.retrieve_audio(&mut dest, &range, 48000, 0x3).unwrap(), + RetrieveAudioStatus::Unsupported + ); + // No host GPU context is installed in tests: the import declines + // cleanly (Ok(None)) instead of erroring. + assert!(d.retrieve_video_frame_gpu(&video_params()).is_ok()); + d.close().expect("close"); + + // An audio session cannot produce video, on either path. + let audio = demo_stream(1); + d.open(&audio).expect("open audio"); + assert!(d.retrieve_video_frame(&video_params()).is_err()); + assert!(d.retrieve_video(&video_params()).is_err()); + assert!(d.retrieve_video_frame_gpu(&video_params()).is_err()); + // Invalid audio arguments are unsupported, not a crash. + assert_eq!( + d.retrieve_audio(&mut dest, &range, 0, 0x3).unwrap(), + RetrieveAudioStatus::Unsupported + ); + assert_eq!( + d.retrieve_audio(&mut dest, &range, 48000, 0).unwrap(), + RetrieveAudioStatus::Unsupported + ); + d.close().expect("close"); + } + + // ---- extended coverage: pure helpers --------------------------------- + + fn small_frame(format: Pixel, pts: Option) -> ffmpeg::frame::Video { + let mut f = ffmpeg::frame::Video::new(format, 2, 2); + f.set_pts(pts); + f + } + + fn cached_frame(pts: Option) -> RefFrame { + RefFrame::from_video(&small_frame(Pixel::YUV420P, pts)).expect("reference frame") + } + + fn video_state( + cache: Vec, + cache_at_zero: bool, + cache_at_eof: bool, + ) -> VideoDecodeState { + VideoDecodeState { + scaler: None, + cache: cache.into(), + cache_at_zero, + cache_at_eof, + second_ts: 10, + eof_fallback_warned: false, + } + } + + #[test] + fn decoder_default_state_accessors() { + let d = FFmpegDecoder::default(); + assert!(d.hw_decoder_name().is_none()); + assert_eq!(d.audio_seek_count(), 0); + assert!(!d.hardware_decoding()); + assert_eq!(d.stream().filename(), ""); + } + + #[test] + fn ref_frame_unset_pts_and_debug() { + let no_pts = cached_frame(None); + assert_eq!(no_pts.pts(), None); + assert!(format!("{no_pts:?}").contains("pts")); + assert_eq!((no_pts.width(), no_pts.height()), (2, 2)); + + let with_pts = cached_frame(Some(7)); + assert_eq!(with_pts.pts(), Some(7)); + + // `clone_raw` shares the buffers of a raw frame and reports the pts. + let raw = RefFrame::clone_raw(with_pts.as_ptr()).expect("clone_raw"); + assert_eq!(raw.pts(), Some(7)); + assert!(raw.to_video().is_some()); + } + + #[test] + fn get_frame_from_cache_covers_boundaries_and_eof() { + let cache = vec![cached_frame(Some(0)), cached_frame(Some(10)), cached_frame(Some(20))]; + + // Before the front: the front frame only when the cache came from + // a zero seek; otherwise a miss. + let s = video_state(cache.clone(), true, false); + assert_eq!(get_frame_from_cache(&s, -5).unwrap().pts(), Some(0)); + let s = video_state(cache.clone(), false, false); + assert!(get_frame_from_cache(&s, -5).is_none()); + + // Inside the cache: exact hits and the previous frame for gaps. + let s = video_state(cache.clone(), false, false); + assert_eq!(get_frame_from_cache(&s, 0).unwrap().pts(), Some(0)); + assert_eq!(get_frame_from_cache(&s, 5).unwrap().pts(), Some(0)); + assert_eq!(get_frame_from_cache(&s, 15).unwrap().pts(), Some(10)); + assert_eq!(get_frame_from_cache(&s, 20).unwrap().pts(), Some(20)); + assert!(get_frame_from_cache(&s, 21).is_none()); + + // Past the back: the last frame only at EOF. + let s = video_state(cache.clone(), false, true); + assert_eq!(get_frame_from_cache(&s, 25).unwrap().pts(), Some(20)); + let s = video_state(cache.clone(), false, false); + assert!(get_frame_from_cache(&s, 25).is_none()); + + // An empty cache never yields a frame. + let s = video_state(Vec::new(), true, true); + assert!(get_frame_from_cache(&s, 0).is_none()); + } + + #[test] + fn frame_interval_ts_edges() { + let two = video_state(vec![cached_frame(Some(0)), cached_frame(Some(10))], false, false); + assert_eq!(DecoderState::frame_interval_ts(&two), Some(10)); + + let irregular = video_state( + vec![ + cached_frame(Some(0)), + cached_frame(Some(30)), + cached_frame(Some(40)), + ], + false, + false, + ); + assert_eq!(DecoderState::frame_interval_ts(&irregular), Some(10)); + + let same = video_state(vec![cached_frame(Some(5)), cached_frame(Some(5))], false, false); + assert_eq!(DecoderState::frame_interval_ts(&same), None); + + let single = video_state(vec![cached_frame(Some(5))], false, false); + assert_eq!(DecoderState::frame_interval_ts(&single), None); + + let no_pts = video_state(vec![cached_frame(None), cached_frame(None)], false, false); + assert_eq!(DecoderState::frame_interval_ts(&no_pts), None); + } + + #[test] + fn frame_colorimetry_resolves_force_and_jpeg_ranges() { + let plain = small_frame(Pixel::YUV420P, Some(0)); + assert!(!unsafe { frame_colorimetry(plain.as_ptr(), 99).3 }, "untagged is limited"); + assert!(unsafe { frame_colorimetry(plain.as_ptr(), oak_core_COLOR_RANGE_FULL).3 }); + assert!(!unsafe { frame_colorimetry(plain.as_ptr(), oak_core_COLOR_RANGE_LIMITED).3 }); + + let mut full = small_frame(Pixel::YUV420P, Some(0)); + full.set_color_range(ffmpeg::color::Range::JPEG); + assert!( + unsafe { frame_colorimetry(full.as_ptr(), 99).3 }, + "JPEG is full range" + ); + + // A YUVJ source is full range by definition. + let jpeg = small_frame(Pixel::YUVJ420P, Some(0)); + assert!(unsafe { frame_colorimetry(jpeg.as_ptr(), 99).3 }); + } + + #[test] + fn pix_fmt_depth_handles_missing_descriptor() { + assert_eq!(pix_fmt_depth_and_yuv(Pixel::None), (8, false)); + } + + #[test] + fn convert_rgba_f32_le_honours_stride_and_forces_opaque_alpha() { + let (w, h) = (2u32, 2u32); + let stride = 2 * PIXEL_F32_BYTES + 8; // padded rows + let mut data = vec![0u8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + let off = y * stride + x * PIXEL_F32_BYTES; + for c in 0..4 { + data[off + c * 4..off + c * 4 + 4] + .copy_from_slice(&(c as f32 + 0.5).to_le_bytes()); + } + } + } + let out = convert_rgba_f32_le(&data, w, h, stride); + assert_eq!(out.len(), (w * h) as usize * PIXEL_F32_BYTES); + for px in out.as_chunks::().0 { + assert_eq!(f32::from_le_bytes(px[0..4].try_into().unwrap()), 0.5); + assert_eq!(f32::from_le_bytes(px[4..8].try_into().unwrap()), 1.5); + assert_eq!( + f32::from_le_bytes(px[12..16].try_into().unwrap()), + 1.0, + "alpha is forced opaque" + ); + } + } + + #[test] + fn convert_rgba8_to_f32_maps_unorm_channels() { + let data = [ + 0u8, 255, 128, 255, // pixel 0 + 255, 0, 0, 0, // pixel 1 + ]; + let out = convert_rgba8_to_f32(&data, 2, 1, 8); + assert_eq!(out.len(), 2 * PIXEL_F32_BYTES); + let px = |i: usize| f32::from_le_bytes(out[i * 4..i * 4 + 4].try_into().unwrap()); + assert_eq!(px(0), 0.0); + assert_eq!(px(1), 1.0); + assert!((px(2) - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(px(3), 1.0); + assert_eq!(px(4), 1.0); + assert_eq!(px(7), 0.0); + } + + #[test] + fn export_codec_mapping_covers_every_code() { + use ffmpeg::codec::Id; + let cases: [(i32, Id); 15] = [ + (0, Id::DNXHD), + (1, Id::H264), + (2, Id::H264), + (3, Id::HEVC), + (6, Id::PRORES), + (7, Id::CFHD), + (10, Id::MPEG2VIDEO), + (11, Id::MP3), + (12, Id::AAC), + (13, Id::PCM_S16LE), + (14, Id::OPUS), + (15, Id::VORBIS), + (16, Id::FLAC), + (17, Id::SUBRIP), + (18, Id::AV1), + ]; + for (code, id) in cases { + assert_eq!(export_codec_to_id(code), Some(id), "code {code}"); + } + for code in [4, 5, 8, 9, 19, -1, 99] { + assert_eq!(export_codec_to_id(code), None, "unknown code {code}"); + } + } + + #[test] + fn default_encoder_formats_per_codec() { + use ffmpeg::codec::Id; + assert_eq!( + default_pixel_format_for_codec(Id::PRORES), + Pixel::YUV422P10LE + ); + assert_eq!( + default_pixel_format_for_codec(Id::DNXHD), + Pixel::YUV422P10LE + ); + assert_eq!(default_pixel_format_for_codec(Id::H264), Pixel::YUV420P); + assert!(matches!( + default_sample_format_for_codec(Id::PCM_S16LE), + Sample::I16(SampleType::Packed) + )); + assert!(matches!( + default_sample_format_for_codec(Id::FLAC), + Sample::I16(SampleType::Planar) + )); + assert!(matches!( + default_sample_format_for_codec(Id::MP3), + Sample::F32(SampleType::Packed) + )); + assert!(matches!( + default_sample_format_for_codec(Id::AAC), + Sample::F32(SampleType::Planar) + )); + } + + #[test] + fn pixel_format_from_name_parses_and_rejects() { + let empty = [0u8; 64]; + assert_eq!(pixel_format_from_name(&empty), None); + + let mut named = [0u8; 64]; + named[..7].copy_from_slice(b"yuv420p"); + assert_eq!(pixel_format_from_name(&named), Some(Pixel::YUV420P)); + + // No NUL terminator, unparseable name. + let full = [b'x'; 64]; + assert_eq!(pixel_format_from_name(&full), None); + + // Invalid UTF-8 before the terminator. + let mut invalid = [0u8; 64]; + invalid[0] = 0xff; + assert_eq!(pixel_format_from_name(&invalid), None); + } + + #[test] + fn sample_format_to_ffmpeg_covers_every_variant() { + for format in [ + SampleFormat::U8Planar, + SampleFormat::S16Planar, + SampleFormat::S32Planar, + SampleFormat::S64Planar, + SampleFormat::F32Planar, + SampleFormat::F64Planar, + SampleFormat::U8, + SampleFormat::S16, + SampleFormat::S32, + SampleFormat::S64, + SampleFormat::F32, + SampleFormat::F64, + ] { + assert!( + sample_format_to_ffmpeg(format).is_some(), + "{format:?} must map" + ); + } + assert!(sample_format_to_ffmpeg(SampleFormat::Invalid).is_none()); + assert!(matches!( + sample_format_to_ffmpeg(SampleFormat::S16Planar), + Some(Sample::I16(SampleType::Planar)) + )); + assert!(matches!( + sample_format_to_ffmpeg(SampleFormat::F64), + Some(Sample::F64(SampleType::Packed)) + )); + } + + #[test] + fn apply_sws_output_colorspace_covers_matrix_arms() { + fn rgba_to_yuv() -> scaling::Context { + scaling::Context::get( + Pixel::RGBA, + 2, + 2, + Pixel::YUV420P, + 2, + 2, + scaling::Flags::BILINEAR, + ) + .expect("scaler") + } + // 1 = BT.709, 9/10 = BT.2020, everything else = BT.601; both + // limited (0/1) and full (2) ranges. + for (space, range) in [(0, 0), (1, 0), (9, 2), (10, 2), (6, 1), (99, 2)] { + let mut scaler = rgba_to_yuv(); + let p = EncodingParams { + color_space: space, + color_range: range, + ..EncodingParams::default() + }; + apply_sws_output_colorspace(&mut scaler, &p); + } + } + + #[test] + fn extract_source_start_time_reads_timecode_and_bwf() { + let timecode = [( "timecode".to_string(), "00:00:00:10".to_string())]; + let parsed = extract_source_start_time(&timecode, FfRational(1, 25), 0); + assert!(parsed.valid); + assert_eq!(parsed.time, Rational::new(2, 5)); + + // A valid timecode wins even if a time_reference follows. + let both = [ + ("timecode".to_string(), "00:00:00:10".to_string()), + ("time_reference".to_string(), "48000".to_string()), + ]; + let parsed = extract_source_start_time(&both, FfRational(1, 25), 48000); + assert!(parsed.valid); + assert_eq!(parsed.time, Rational::new(2, 5)); + + let bwf = [("time_reference".to_string(), "48000".to_string())]; + let parsed = extract_source_start_time(&bwf, FfRational(1, 1), 48000); + assert!(parsed.valid); + assert_eq!(parsed.time, Rational::new(1, 1)); + + // Invalid values leave the result invalid. + let junk = [ + ("timecode".to_string(), "junk".to_string()), + ("time_reference".to_string(), "also-not-a-number".to_string()), + ("unrelated".to_string(), "x".to_string()), + ]; + let parsed = extract_source_start_time(&junk, FfRational(1, 25), 48000); + assert!(!parsed.valid); + assert!(parsed.time.is_null()); + } + + #[test] + fn stream_time_base_after_header_reports_unset_values() { + let path = + std::env::temp_dir().join(format!("oakcodec_tb_{}.mp4", std::process::id())); + let mut output = ffmpeg::format::output(&path).expect("output context"); + let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MPEG2VIDEO).expect("mpeg2 encoder"); + let stream = output.add_stream(codec).expect("add stream"); + let index = stream.index(); + // Before `write_header` the stream time base is still 0/0. + assert_eq!(stream_time_base_after_header(&output, index), None); + // Out-of-range indices are a clean None. + assert_eq!(stream_time_base_after_header(&output, 12345), None); + drop(output); + let _ = std::fs::remove_file(&path); + } + + // ---- extended coverage: real media ----------------------------------- + + /// Encode a 64x64, 10 fps, 1 s clip with a stereo PCM track. + fn test_clip(tag: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "oakcodec_ffmpeg_{tag}_{}.mp4", + std::process::id() + )); + crate::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip"); + path + } + + fn clip_stream(path: &std::path::Path, index: i32) -> CodecStream { + CodecStream::with_block(path.to_string_lossy().into_owned(), index, None) + } + + fn temp_file(tag: &str, extension: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "oakcodec_ffmpeg_{tag}_{}.{extension}", + std::process::id() + )) + } + + #[test] + fn decode_video_cache_seek_and_eof_fallback() { + let _guard = crate::lock_tests(); + let path = test_clip("video"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 0)).expect("open video"); + let _ = d.hardware_decoding(); + + let mut p = video_params(); + p.time = Rational::new(0, 1); + p.target_size = Some((32, 32)); + p.force_range = oak_core_COLOR_RANGE_FULL; + let frame = d.retrieve_video_frame(&p).expect("frame at 0"); + assert_eq!((frame.width(), frame.height()), (32, 32)); + assert_eq!(frame.format(), PixelFormat::F32); + assert!(!frame.data().expect("pixels").is_empty()); + + // Same request: served from the frame cache. + let cached = d.retrieve_video_frame(&p).expect("cached frame"); + assert_eq!(cached.timestamp(), Rational::new(0, 1)); + + // Before the first frame: the zero-seek cache flag makes the first + // decoded frame come back even though its PTS is after the target. + p.time = Rational::new(-1, 1); + let before = d.retrieve_video_frame(&p).expect("negative time"); + assert_eq!(before.timestamp(), Rational::new(-1, 1)); + + // A later time forces a seek/decode; the limited-range metadata path. + p.time = Rational::new(1, 2); + p.target_size = None; + p.force_range = oak_core_COLOR_RANGE_LIMITED; + let mid = d.retrieve_video_frame(&p).expect("frame at 1/2"); + assert_eq!(mid.width(), 64); + + // A degenerate (zero) target size falls back to the native size. + p.target_size = Some((0, 0)); + let native = d + .retrieve_video_frame(&p) + .expect("zero target falls back"); + assert_eq!(native.width(), 64); + p.target_size = None; + + // `retrieve_video` reports decode success without a texture. + let _token = d.retrieve_video(&p).expect("retrieve_video token"); + + // No host GPU context is installed in tests: the import path + // declines cleanly; on a GPU-equipped host it may import, but it + // must never fail. + assert!(d.retrieve_video_frame_gpu(&p).is_ok()); + + // Audio operations on a video session fail with the medium error. + let error = d + .conform_audio(&["unused.pcm".to_string()], 48000, 0x3, 1, None) + .unwrap_err(); + assert!( + error.to_string().contains("audio stream"), + "the medium error is named: {error}" + ); + + // Format start offset is always a value (stream start or default). + let _offset = d.get_audio_start_offset(); + + // Far beyond the 1 s clip: exercises the EOF fallback (last cached + // frame + once-per-session warning) or a clean decode error. + p.time = Rational::new(30, 1); + let beyond = d.retrieve_video_frame(&p); + if let Ok(frame) = &beyond { + assert_eq!(frame.width(), 64); + } + + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn decode_audio_continuity_and_edge_ranges() { + let _guard = crate::lock_tests(); + let path = test_clip("audio"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 1)).expect("open audio"); + let rate = 48000; + let layout = 0x3; + + let mut dest = vec![0f32; 4800 * 2]; + let range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 10)); + assert_eq!( + d.retrieve_audio(&mut dest, &range, rate, layout).unwrap(), + RetrieveAudioStatus::Success + ); + assert!( + dest.iter().any(|v| v.abs() > 1e-6), + "the sine tone decodes to non-silence" + ); + let seeks = d.audio_seek_count(); + assert!(seeks >= 1, "the first chunk seeks"); + + // The immediately following chunk continues without a seek. + let mut next = vec![0f32; 4800 * 2]; + let next_range = TimeRange::new(Rational::new(1, 10), Rational::new(1, 5)); + assert_eq!( + d.retrieve_audio(&mut next, &next_range, rate, layout).unwrap(), + RetrieveAudioStatus::Success + ); + assert_eq!( + d.audio_seek_count(), + seeks, + "a contiguous chunk must not re-seek" + ); + + // A non-contiguous chunk seeks again. + let mut jump = vec![0f32; 4800 * 2]; + let jump_range = TimeRange::new(Rational::new(1, 2), Rational::new(3, 5)); + assert_eq!( + d.retrieve_audio(&mut jump, &jump_range, rate, layout).unwrap(), + RetrieveAudioStatus::Success + ); + assert!(d.audio_seek_count() > seeks, "a jump re-seeks"); + + // An empty range succeeds without decoding (start == end). + let mut empty_dest = [0f32; 8]; + let empty = TimeRange::new(Rational::new(1, 5), Rational::new(1, 5)); + assert_eq!( + d.retrieve_audio(&mut empty_dest, &empty, rate, layout).unwrap(), + RetrieveAudioStatus::Success + ); + + // A destination shorter than the range: clipped fill, still success. + let mut short_dest = vec![0f32; 480]; + assert_eq!( + d.retrieve_audio(&mut short_dest, &next_range, rate, layout).unwrap(), + RetrieveAudioStatus::Success + ); + + // A zero-length destination still decodes (the overflow is kept as + // the next chunk's carry). + let mut zero_dest: [f32; 0] = []; + assert_eq!( + d.retrieve_audio(&mut zero_dest, &range, rate, layout).unwrap(), + RetrieveAudioStatus::Success + ); + + // A mono destination exercises a one-channel layout conversion. + let mut mono = vec![0f32; 4410]; + let mono_range = TimeRange::new(Rational::new(3, 10), Rational::new(2, 5)); + assert_eq!( + d.retrieve_audio(&mut mono, &mono_range, 44100, 0x4) + .unwrap(), + RetrieveAudioStatus::Success + ); + + // A rate conversion leaves samples buffered inside the resampler; + // the flush tail is appended after the decoded frames. + let mut resampled = vec![0f32; 4410 * 2]; + let resample_range = TimeRange::new(Rational::new(1, 5), Rational::new(3, 10)); + assert_eq!( + d.retrieve_audio(&mut resampled, &resample_range, 44100, layout) + .unwrap(), + RetrieveAudioStatus::Success + ); + assert!( + resampled.iter().any(|v| v.abs() > 1e-6), + "the resampled range is not silent" + ); + + // Invalid output parameters are Unsupported, not errors. + assert_eq!( + d.retrieve_audio(&mut dest, &range, 0, layout).unwrap(), + RetrieveAudioStatus::Unsupported + ); + assert_eq!( + d.retrieve_audio(&mut dest, &range, rate, 0).unwrap(), + RetrieveAudioStatus::Unsupported + ); + + let _ = d.get_audio_start_offset(); + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn retrieve_frame_honours_cancel_atom_and_any_timecode() { + let _guard = crate::lock_tests(); + let path = test_clip("frame_cancel"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 0)).expect("open video"); + + let atom = CancelAtom::new(); + atom.cancel(); + { + let mut state = d.state.lock().unwrap_or_else(|e| e.into_inner()); + let state = state.as_mut().expect("open state"); + // A pre-cancelled retrieve decodes nothing and is not an error. + let frame = state + .retrieve_frame(&Rational::new(0, 1), false, Some(&atom)) + .expect("cancelled retrieve"); + assert!(frame.is_none(), "no frame after the cancel"); + + // `any_timecode` skips the seek/cache block and returns the + // next decoded frame regardless of its PTS. + let frame = state + .retrieve_frame(&Rational::new(0, 1), true, None) + .expect("any-timecode retrieve"); + assert_eq!(frame.expect("a frame").pts(), Some(0)); + } + + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn retrieve_audio_failure_clears_continuity_state() { + let _guard = crate::lock_tests(); + let path = test_clip("audio_fail"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 1)).expect("open audio"); + + // Decode one good chunk to seed the continuity state. + let mut dest = vec![0f32; 4800 * 2]; + let range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 10)); + assert_eq!( + d.retrieve_audio(&mut dest, &range, 48000, 0x3).unwrap(), + RetrieveAudioStatus::Success + ); + + // Break the input definition: the next (contiguous) chunk's + // resampler creation fails, and the failure must clear the + // continuity/carry state so the following chunk re-seeks fresh. + { + let mut state = d.state.lock().unwrap_or_else(|e| e.into_inner()); + let state = state.as_mut().expect("open state"); + state.input_sample_rate = 0; + state.audio.as_mut().expect("audio session").resampler = None; + } + let next = TimeRange::new(Rational::new(1, 10), Rational::new(1, 5)); + let error = d.retrieve_audio(&mut dest, &next, 48000, 0x3); + assert!(error.is_err(), "a broken resampler definition fails"); + { + let state = d.state.lock().unwrap_or_else(|e| e.into_inner()); + let audio = state.as_ref().unwrap().audio.as_ref().unwrap(); + assert!( + audio.contiguous_end_sample.is_none(), + "the failure clears continuity" + ); + assert!(audio.carry.is_empty(), "the failure clears the carry"); + } + + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn conform_audio_planar_packed_and_error_paths() { + let _guard = crate::lock_tests(); + let path = test_clip("conform"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 1)).expect("open audio"); + + // S16 planar (code 1): one output file per channel. + let left = temp_file("conform_l", "pcm"); + let right = temp_file("conform_r", "pcm"); + d.conform_audio( + &[ + left.to_string_lossy().into_owned(), + right.to_string_lossy().into_owned(), + ], + 48000, + 0x3, + 1, + None, + ) + .expect("planar conform"); + assert!(std::fs::metadata(&left).unwrap().len() > 0); + assert!(std::fs::metadata(&right).unwrap().len() > 0); + + // F32 packed (code 10): a single interleaved output file. + let packed = temp_file("conform_p", "pcm"); + d.conform_audio( + &[packed.to_string_lossy().into_owned()], + 48000, + 0x3, + 10, + None, + ) + .expect("packed conform"); + assert!(std::fs::metadata(&packed).unwrap().len() > 0); + + // A missing output directory fails cleanly. + let missing = std::env::temp_dir() + .join("oakcodec_missing_dir_for_conform") + .join("out.pcm"); + assert!(d + .conform_audio( + &[missing.to_string_lossy().into_owned()], + 48000, + 0x3, + 1, + None + ) + .is_err()); + + // A session without an input layout cannot conform. + { + let mut state = d.state.lock().unwrap_or_else(|e| e.into_inner()); + state.as_mut().expect("open state").input_channel_layout_mask = 0; + } + let error = d + .conform_audio( + &[left.to_string_lossy().into_owned()], + 48000, + 0x3, + 1, + None, + ) + .unwrap_err(); + assert!( + error.to_string().contains("channel layout"), + "the layout error is named: {error}" + ); + + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&left); + let _ = std::fs::remove_file(&right); + let _ = std::fs::remove_file(&packed); + } + + #[test] + fn conform_audio_cancel_reports_cancelled() { + let _guard = crate::lock_tests(); + let path = test_clip("conform_cancel"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 1)).expect("open audio"); + + let out = temp_file("conform_cancel_out", "pcm"); + let atom = CancelAtom::new(); + atom.cancel(); + let error = d + .conform_audio( + &[out.to_string_lossy().into_owned()], + 48000, + 0x3, + 1, + Some(&atom), + ) + .unwrap_err(); + assert!( + matches!(error, crate::error::Error::Cancelled), + "a cancelled conform reports Cancelled, got {error:?}" + ); + + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&out); + } + + #[test] + fn probe_reports_streams_subtitles_and_cancel() { + let _guard = crate::lock_tests(); + let path = test_clip("probe"); + let d = FFmpegDecoder::new(); + + let desc = d + .probe(&path.to_string_lossy(), None) + .expect("probe clip"); + assert_eq!(desc.decoder(), "ffmpeg"); + assert_eq!(desc.total_stream_count(), 2); + assert_eq!(desc.video_stream_count(), 1); + assert_eq!(desc.audio_stream_count(), 1); + let video = desc.get_video_stream(0).expect("video stream"); + assert_eq!((video.width(), video.height()), (64, 64)); + let audio = desc.get_audio_stream(0).expect("audio stream"); + assert_eq!(audio.sample_rate, 48000); + + // A pre-cancelled probe stops before reading any stream. + let atom = CancelAtom::new(); + atom.cancel(); + assert!(d.probe(&path.to_string_lossy(), Some(&atom)).is_none()); + + // A subtitle-only file: the stream is counted, but no subtitle + // entry is added (the probe's fall-through arm). + let srt = temp_file("subs", "srt"); + std::fs::write( + &srt, + "1\n00:00:00,000 --> 00:00:01,000\nhello\n", + ) + .expect("write srt"); + let desc = d + .probe(&srt.to_string_lossy(), None) + .expect("probe srt"); + assert_eq!(desc.total_stream_count(), 1); + assert_eq!(desc.subtitle_stream_count(), 0); + assert_eq!(desc.video_stream_count(), 0); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&srt); + } + + #[test] + fn decoder_open_rejects_subtitle_stream() { + let _guard = crate::lock_tests(); + let srt = temp_file("open_subs", "srt"); + std::fs::write(&srt, "1\n00:00:00,000 --> 00:00:01,000\nhello\n").expect("write srt"); + let d = FFmpegDecoder::new(); + let error = d + .open(&CodecStream::with_block( + srt.to_string_lossy().into_owned(), + 0, + None, + )) + .expect_err("subtitle streams are not video/audio"); + assert!( + error.to_string().contains("expected video or audio"), + "the error names the medium: {error}" + ); + let _ = std::fs::remove_file(&srt); + } + + /// 64x64 F32-RGBA frame helper for the encoder tests. + fn f32_frame(width: i32, height: i32, timestamp: Rational) -> Frame { + let mut vp = VideoParams::new_basic( + width, + height, + OakPixelFormat::from_code(0), + 4, + 1, + 1, + 0, + 1, + ); + vp.set_format(OakPixelFormat::from_code(PixelFormat::F32 as i32)); + let mut frame = Frame::with_params(vp); + frame.set_timestamp(timestamp); + frame.allocate().expect("frame allocation"); + frame + } + + fn encoder_params(path: &std::path::Path, audio: bool) -> EncodingParams { + let mut p = EncodingParams::default(); + let name = path.as_os_str().as_encoded_bytes(); + assert!(name.len() < p.filename.len(), "temp path too long"); + p.filename[..name.len()].copy_from_slice(name); + p.format = 2; // MPEG-4 video + p.video_enabled = 1; + p.video_codec = 10; // MPEG-2 (B-frame-free) + p.video_width = 64; + p.video_height = 64; + p.video_time_base_num = 1; + p.video_time_base_den = 10; + p.video_bit_rate = 2_000_000; + p.video_max_bit_rate = 4_000_000; + p.video_pixel_format = PixelFormat::F32; + p.video_pix_fmt[..7].copy_from_slice(b"yuv420p"); + p.video_pixel_aspect_num = 1; + p.video_pixel_aspect_den = 1; + p.color_primaries = 1; // BT.709 + p.color_trc = 1; + p.color_space = 9; // BT.2020 (exercises the matrix arm) + p.color_range = 2; // full + if audio { + p.audio_enabled = 1; + // PCM (not AAC): the AAC encoder rejects the resampler's + // non-1024-sample middle frames in this FFmpeg pairing (the + // same reason `testmedia` uses PCM). + p.audio_codec = 13; // PCM S16LE + p.audio_sample_rate = 48000; + p.audio_channel_layout = 0x3; + p.audio_sample_format = SampleFormat::F32; + p.audio_bit_rate = 0; + } + p + } + + #[test] + fn encoder_roundtrip_covers_guard_and_error_paths() { + let _guard = crate::lock_tests(); + let path = temp_file("enc_roundtrip", "mp4"); + let e = FFmpegEncoder::with_params(encoder_params(&path, true)); + e.open().expect("open video+audio"); + e.open().expect("second open is a no-op"); + + let first = f32_frame(64, 64, Rational::new(0, 1)); + e.write_video(&first).expect("first frame"); + let second = f32_frame(64, 64, Rational::new(1, 10)); + e.write_video(&second).expect("second frame"); + + // A frame of the wrong size is rejected. + let wrong = f32_frame(32, 32, Rational::new(2, 10)); + let error = e.write_video(&wrong).unwrap_err(); + assert!( + error.to_string().contains("does not match"), + "the size error is named: {error}" + ); + + // Audio: chunked writes plus an empty (no-op) write. + let tone = vec![0.1f32; 960 * 2]; + e.write_audio(&tone, 960).expect("audio chunk"); + e.write_audio(&tone, 960).expect("second audio chunk"); + e.write_audio(&[], 0).expect("empty audio is a no-op"); + + // The already-flushed guard branches (defensive: `close` clears the + // output, so mark the state directly). + { + let mut state = e.state.lock().unwrap_or_else(|x| x.into_inner()); + state.output.as_mut().expect("open output").flushed = true; + } + assert!(e.write_video(&first).is_err()); + assert!(e.write_audio(&tone, 4800).is_err()); + { + let mut state = e.state.lock().unwrap_or_else(|x| x.into_inner()); + state.output.as_mut().expect("open output").flushed = false; + } + + e.close().expect("close flushes"); + assert!(e.close().is_ok(), "close is idempotent"); + e.flush().expect("flush after close is safe"); + assert!(std::fs::metadata(&path).expect("output exists").len() > 0); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn encoder_open_rejects_unknown_codecs() { + let path = temp_file("enc_bad_codec", "mp4"); + + let mut p = EncodingParams::default(); + let name = path.as_os_str().as_encoded_bytes(); + p.filename[..name.len()].copy_from_slice(name); + p.format = 2; + p.video_enabled = 1; + p.video_codec = 99; + p.video_width = 64; + p.video_height = 64; + p.video_time_base_num = 1; + p.video_time_base_den = 10; + let error = FFmpegEncoder::with_params(p).open().unwrap_err(); + assert!( + error.to_string().contains("unknown video codec"), + "unknown video codec is named: {error}" + ); + + let mut p = EncodingParams::default(); + p.filename[..name.len()].copy_from_slice(name); + p.format = 2; + p.audio_enabled = 1; + p.audio_codec = 99; + p.audio_sample_rate = 48000; + p.audio_channel_layout = 0x3; + let error = FFmpegEncoder::with_params(p).open().unwrap_err(); + assert!( + error.to_string().contains("unknown audio codec"), + "unknown audio codec is named: {error}" + ); + + let _ = std::fs::remove_file(&path); + } + + // ---- extended coverage: session internals ----------------------------- + + /// `DecoderState::reopen_software` swaps the open session for a fresh + /// pure-software one (the hardware fallback's core); the swapped + /// session must still decode. + #[test] + fn reopen_software_swaps_in_a_software_session() { + let _guard = crate::lock_tests(); + let path = test_clip("reopen_sw"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 0)).expect("open video"); + let hardware_before = d.hardware_decoding(); + { + let mut state = d.state.lock().unwrap_or_else(|e| e.into_inner()); + let state = state.as_mut().expect("open state"); + state.reopen_software().expect("software reopen"); + assert!( + state.hw_device.is_none(), + "the reopened session is software" + ); + } + if hardware_before { + assert!( + !d.hardware_decoding(), + "the software swap dropped the hardware device" + ); + } + let frame = d + .retrieve_video_frame(&video_params()) + .expect("decode after the reopen"); + assert!(frame.width() > 0 && frame.height() > 0); + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + } + + /// Draining a video session through `any_timecode` ends in the codec-EOF + /// branch: the last cached frame is returned (with the once-per-session + /// mismatch warning), not an error. + #[test] + fn any_timecode_drain_reaches_the_eof_fallback() { + let _guard = crate::lock_tests(); + let path = test_clip("eof_fallback"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 0)).expect("open video"); + { + let mut state = d.state.lock().unwrap_or_else(|e| e.into_inner()); + let state = state.as_mut().expect("open state"); + let mut last_pts = None; + for _ in 0..10 { + let frame = state + .retrieve_frame(&Rational::new(0, 1), true, None) + .expect("any-timecode retrieve") + .expect("a decoded frame"); + last_pts = frame.pts(); + } + assert!(last_pts.is_some(), "the last frame carries its PTS"); + + // The decoder is drained: both extra retrieves fall back to the + // last cached frame instead of failing. + for _ in 0..2 { + let frame = state + .retrieve_frame(&Rational::new(0, 1), true, None) + .expect("EOF fallback retrieve") + .expect("the last cached frame"); + assert_eq!(frame.pts(), last_pts, "EOF fallback returns the tail"); + } + } + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + } + + /// `c_string_1024` reads a NUL-terminated name and falls back to the + /// full buffer when no terminator exists. + #[test] + fn encoder_filename_reads_full_unterminated_buffer() { + let params = EncodingParams { + filename: [b'f'; 1024], + ..Default::default() + }; + let encoder = FFmpegEncoder::with_params(params); + assert_eq!(encoder.filename().len(), 1024); + assert!(encoder.filename().bytes().all(|b| b == b'f')); + + // The default params buffer is all-NUL and reads as empty. + let encoder = FFmpegEncoder::with_params(EncodingParams::default()); + assert_eq!(encoder.filename(), ""); + } + + /// The resampler's plane/frame conversion and its draining flush + /// (the pieces the chunked decode/encode paths call into). + #[test] + fn audio_resampler_planes_frames_and_flush_tail() { + let mut resampler = AudioResampler::get( + Sample::F32(SampleType::Packed), + ChannelLayout::default(2), + 48000, + Sample::F32(SampleType::Packed), + ChannelLayout::default(2), + 44100, + ) + .expect("resampler"); + let mut input = ffmpeg::frame::Audio::new( + Sample::F32(SampleType::Packed), + 1000, + ChannelLayout::default(2), + ); + for (i, chunk) in input + .data_mut(0) + .as_chunks_mut::<4>() + .0 + .iter_mut() + .enumerate() + { + let v = ((i / 2) as f32 * 440.0 * std::f32::consts::TAU / 48000.0).sin() * 0.5; + chunk.copy_from_slice(&v.to_le_bytes()); + } + + let planes = resampler.convert_to_planes(&input).expect("convert planes"); + assert!(!planes.is_empty()); + assert!(planes.iter().all(|p| !p.is_empty())); + + let frame = resampler.convert_to_frame(&input).expect("convert frame"); + assert!(frame.samples() > 0, "the frame carries resampled samples"); + + // A rate conversion leaves a tail buffered inside swr; draining it + // exercises the flush loop's read/write body. + let tail = resampler.flush_planes().expect("flush"); + let written: usize = tail.iter().map(|p| p.first().map_or(0, Vec::len)).sum(); + assert!(written > 0, "the flush tail carries samples"); + // A second flush finds nothing buffered. + assert!(resampler.flush_planes().expect("flush again").is_empty()); + } + + /// `conform_audio` at a different rate exercises the resampler-tail + /// flush write arm; a zero output rate fails resampler creation. + #[test] + fn conform_audio_rate_conversion_flushes_the_tail() { + let _guard = crate::lock_tests(); + let path = test_clip("conform_rate"); + let d = FFmpegDecoder::new(); + d.open(&clip_stream(&path, 1)).expect("open audio"); + let out = temp_file("conform_rate_out", "pcm"); + d.conform_audio(&[out.to_string_lossy().into_owned()], 44100, 0x3, 1, None) + .expect("conform at 44100"); + assert!(std::fs::metadata(&out).unwrap().len() > 0); + + // A zero output rate cannot create the resampler. + assert!(d + .conform_audio(&[out.to_string_lossy().into_owned()], 0, 0x3, 1, None) + .is_err()); + + d.close().expect("close"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&out); + } } diff --git a/crates/oak-codec/src/framemanager.rs b/crates/oak-codec/src/framemanager.rs index 14e9a2f43..bcf253cb6 100644 --- a/crates/oak-codec/src/framemanager.rs +++ b/crates/oak-codec/src/framemanager.rs @@ -214,4 +214,12 @@ mod tests { mgr.clear(); assert_eq!(mgr.pool.lock().unwrap().len(), 0); } + + #[test] + fn instance_is_a_process_singleton() { + let a = FrameManager::instance(); + let b = FrameManager::instance(); + assert!(std::ptr::eq(a, b)); + a.clear(); + } } diff --git a/crates/oak-codec/src/hwdecode.rs b/crates/oak-codec/src/hwdecode.rs index 14d76c356..84e694edb 100644 --- a/crates/oak-codec/src/hwdecode.rs +++ b/crates/oak-codec/src/hwdecode.rs @@ -72,12 +72,37 @@ pub const CONFIG_KEY_HARDWARE_DECODING: &str = "HardwareDecoding"; static UNAVAILABLE_DEVICES: [std::sync::atomic::AtomicBool; 64] = [const { std::sync::atomic::AtomicBool::new(false) }; 64]; -/// Test-only counter of `open_hw_accel` device-context creation attempts -/// (incremented at the top of the function, before any FFmpeg call). Lets -/// tests prove the negative cache short-circuits before FFmpeg is -/// involved. #[cfg(test)] -static CREATE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +thread_local! { + /// Test-only counter of `open_hw_accel` device-context creation + /// attempts on the calling thread (incremented at the top of the + /// function, before any FFmpeg call). Lets tests prove the negative + /// cache short-circuits before FFmpeg is involved. + /// + /// Thread-local on purpose: the real-media tests decode on other + /// threads *without* the shared test lock, so a process-wide counter + /// would let their attempts perturb the negative-cache assertion on + /// this thread. + static CREATE_ATTEMPTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The calling thread's creation-attempt count (tests only). +#[cfg(test)] +fn creation_attempts() -> u64 { + CREATE_ATTEMPTS.with(std::cell::Cell::get) +} + +/// Count one creation attempt on the calling thread (tests only). +#[cfg(test)] +fn note_creation_attempt() { + CREATE_ATTEMPTS.with(|count| count.set(count.get() + 1)); +} + +/// Reset the calling thread's creation-attempt count (tests only). +#[cfg(test)] +fn reset_creation_attempts() { + CREATE_ATTEMPTS.with(|count| count.set(0)); +} /// Whether `device_type` is known unavailable — a device-context /// creation failed once earlier in this process. @@ -178,9 +203,7 @@ pub fn open_hw_accel( return None; } #[cfg(test)] - { - CREATE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } + note_creation_attempt(); let mut context = ffmpeg::codec::Context::from_parameters(params.clone()).ok()?; let mut device: *mut sys::AVBufferRef = std::ptr::null_mut(); // SAFETY: `device` is a valid out-pointer; on success it owns the @@ -329,6 +352,10 @@ mod tests { /// boxes without the driver). #[test] fn negative_cache_skips_marked_device() { + // Serialize with the media tests that hold the shared test lock + // (the counter itself is thread-local, but the negative cache is + // process-wide). + let _lock = crate::lock_tests(); // VDPAU is not a candidate on any supported platform, so marking // it cannot disturb the platform tests in this process (e.g. the // macOS VideoToolbox test above). @@ -342,11 +369,11 @@ mod tests { let params = fstream.parameters(); let codec = ffmpeg::decoder::find(params.id()).expect("software h264 codec"); - CREATE_ATTEMPTS.store(0, std::sync::atomic::Ordering::Relaxed); + reset_creation_attempts(); let opened = open_hw_accel(¶ms, codec, dev); assert!(opened.is_none(), "marked device must not open"); assert_eq!( - CREATE_ATTEMPTS.load(std::sync::atomic::Ordering::Relaxed), + creation_attempts(), 0, "marked device must be skipped before any creation attempt" ); @@ -365,6 +392,3 @@ mod tests { assert!(!device_unavailable(dev)); } } -+ /// would let their attempts perturb the negative-cache assertion on -+#[cfg(test)] -+#[cfg(test)] diff --git a/crates/oak-codec/src/proxymanager.rs b/crates/oak-codec/src/proxymanager.rs index 25ab36b63..7853a2eac 100644 --- a/crates/oak-codec/src/proxymanager.rs +++ b/crates/oak-codec/src/proxymanager.rs @@ -769,4 +769,86 @@ mod tests_extra { ); } } - ); + +#[cfg(test)] +mod tests_coverage { + use super::*; + + #[test] + fn proxy_state_numeric_and_string_conversions() { + assert_eq!(ProxyState::try_from(0), Ok(ProxyState::Missing)); + assert_eq!(ProxyState::try_from(1), Ok(ProxyState::Generating)); + assert_eq!(ProxyState::try_from(2), Ok(ProxyState::Ready)); + assert_eq!(ProxyState::try_from(3), Ok(ProxyState::Failed)); + assert_eq!(ProxyState::try_from(4), Err(())); + assert_eq!(ProxyState::try_from(-1), Err(())); + + for state in [ + ProxyState::Missing, + ProxyState::Generating, + ProxyState::Ready, + ProxyState::Failed, + ] { + let name = ProxyManager::proxy_state_to_string(state); + assert_eq!(ProxyManager::proxy_state_from_string(&name), state); + assert_eq!( + ProxyManager::proxy_state_from_string(&(state as i32).to_string()), + state + ); + } + + // Whitespace is trimmed; unknown names fall back to Missing. + assert_eq!( + ProxyManager::proxy_state_from_string(" generating "), + ProxyState::Generating + ); + assert_eq!( + ProxyManager::proxy_state_from_string("bogus"), + ProxyState::Missing + ); + assert_eq!(ProxyManager::proxy_state_from_string(""), ProxyState::Missing); + } + + #[test] + fn proxy_filename_audio_tag_detection() { + assert!(ProxyManager::proxy_filename_has_audio("/c/shot.a1.mp4")); + assert!(ProxyManager::proxy_filename_has_audio("shot.a1.mov")); + assert!(!ProxyManager::proxy_filename_has_audio("/c/shot.a2.mp4")); + assert!(!ProxyManager::proxy_filename_has_audio("/c/shot.mp4")); + assert!(!ProxyManager::proxy_filename_has_audio("")); + } + + #[test] + fn get_proxy_state_reads_the_filesystem() { + assert_eq!(ProxyManager::get_proxy_state(""), ProxyState::Missing); + + let dir = std::env::temp_dir().join(format!("oakcodec-proxy-coverage-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + // A finished proxy exists on disk. + let proxy = dir.join("clip.mp4"); + std::fs::write(&proxy, b"proxy").unwrap(); + assert_eq!( + ProxyManager::get_proxy_state(&proxy.to_string_lossy()), + ProxyState::Ready + ); + + // Only the working file exists: still generating. + let pending = dir.join("pending.mp4"); + let working = + ProxyManager::get_working_filename(&pending.to_string_lossy()).unwrap(); + std::fs::write(&working, b"partial").unwrap(); + assert_eq!( + ProxyManager::get_proxy_state(&pending.to_string_lossy()), + ProxyState::Generating + ); + + // Neither file exists. + assert_eq!( + ProxyManager::get_proxy_state(&dir.join("none.mp4").to_string_lossy()), + ProxyState::Missing + ); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/oak-core/src/backend.rs b/crates/oak-core/src/backend.rs index 2f32ff5a6..39b2dabed 100644 --- a/crates/oak-core/src/backend.rs +++ b/crates/oak-core/src/backend.rs @@ -2899,11 +2899,38 @@ mod tests { ); } + /// Restores an environment variable on drop, so a test that toggles + /// process-wide configuration cannot leak the value into parallel + /// tests or into a later test when an assertion panics. + struct EnvVarGuard { + key: &'static str, + saved: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let saved = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, saved } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.saved.take() { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } + } + #[test] fn user_config_env_override() { - std::env::set_var("OAK_RENDER_BACKEND", "vulkan"); + // The tests that let the user config pick the shared context read + // the same variable; hold their lock while it is overridden. + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _env = EnvVarGuard::set("OAK_RENDER_BACKEND", "vulkan"); assert_eq!(BackendKind::from_user_config(), BackendKind::Vulkan); - std::env::remove_var("OAK_RENDER_BACKEND"); } #[test] @@ -2966,6 +2993,9 @@ mod tests { /// that has created a texture is no longer replaceable. #[test] fn shared_slot_replaces_an_unused_engine_context() { + // The same shared-state lock as the other slot tests + // (`shared_slot_host_marking_and_queries`): the slot is process-wide. + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let Some(base) = any_gpu() else { return; }; @@ -3325,6 +3355,42 @@ mod tests { ctx.destroy_texture(dst); } + /// The bit-depth-aware YUV transform: the 8-bit variant matches the + /// exact 8-bit code-value expansion (proved against ffmpeg/swscale on + /// real media), and the 16-bit variant is bit-identical with the + /// original `from_matrix` the M2 pass was validated with. + #[test] + fn yuv_transform_depth_matches_reference_math() { + use crate::colormath::YuvMatrix; + let t8 = YuvTransform::from_matrix_depth(YuvMatrix::Bt709, false, 8); + // demo.mp4 @ (960,540): Y=145, U=117, V=164 (limited BT.709). + let (y, u, v) = (145.0f32 / 255.0, 117.0 / 255.0, 164.0 / 255.0); + let rgb = [ + t8.matrix[0][0] * y + t8.matrix[0][2] * v + t8.offset[0], + t8.matrix[1][0] * y + t8.matrix[1][1] * u + t8.matrix[1][2] * v + t8.offset[1], + t8.matrix[2][0] * y + t8.matrix[2][1] * u + t8.offset[2], + ]; + let expect = [0.8421f32, 0.5230, 0.4979]; + for c in 0..3 { + assert!( + (rgb[c] - expect[c]).abs() < 1e-3, + "channel {c}: {} vs {}", + rgb[c], + expect[c] + ); + } + + // The depth-16 constructor is exactly the original formula. + for matrix in [YuvMatrix::Bt601, YuvMatrix::Bt709, YuvMatrix::Bt2020] { + for full in [false, true] { + assert_eq!( + YuvTransform::from_matrix(matrix, full), + YuvTransform::from_matrix_depth(matrix, full, 16) + ); + } + } + } + /// The generic color LUT pass (graph `ColorTransformJob`): trilinear /// LUT application into an `Rgba32Float` texture, GPU→GPU. #[test] @@ -3507,4 +3573,836 @@ mod tests { let mut other = r.create_texture(&pod2, None).unwrap(); assert!(r.blit_color_managed(Some(&src), &mut other, None).is_err()); } + + // ---- Branch-coverage fill-ins ------------------------------------------ + + #[test] + fn backend_kind_strings_cover_every_variant() { + for (kind, s) in [ + (BackendKind::Auto, "auto"), + (BackendKind::Metal, "metal"), + (BackendKind::Vulkan, "vulkan"), + (BackendKind::Gl, "opengl"), + (BackendKind::Cpu, "cpu"), + ] { + assert_eq!(kind.to_config_string(), s); + assert_eq!(BackendKind::from_config_string(s), kind); + } + // The parser trims whitespace and folds case. + assert_eq!( + BackendKind::from_config_string(" VULKAN "), + BackendKind::Vulkan + ); + // `Auto` and `Metal` share the same fallback list; `Cpu` has none. + assert_eq!( + BackendKind::Auto.wgpu_fallbacks(), + BackendKind::Metal.wgpu_fallbacks() + ); + assert_eq!( + BackendKind::Vulkan.wgpu_fallbacks().first(), + Some(&wgpu::Backends::VULKAN) + ); + assert_eq!( + BackendKind::Gl.wgpu_fallbacks().first(), + Some(&wgpu::Backends::GL) + ); + assert!(BackendKind::Cpu.wgpu_fallbacks().is_empty()); + } + + /// The `OAK_REQUIRE_GPU` policy is a pure parser plus two handling + /// arms. Driving both from arguments (instead of flipping the real + /// environment variable) keeps this test from racing the GPU + /// acceptance tests, which read the variable from parallel threads and + /// would panic if they observed a transient `1`. + #[test] + fn require_gpu_adapter_parses_env_values() { + for (value, expected) in [ + (None, false), // unset means "skipping is allowed" + (Some("0"), false), + (Some("false"), false), + (Some("FALSE"), false), + (Some("1"), true), + (Some("yes"), true), + (Some(""), true), + ] { + assert_eq!( + require_gpu_from_value(value), + expected, + "value {value:?}" + ); + } + // The soft-skip arm logs and returns instead of failing. + skip_or_fail_gpu_with(false, "a coverage probe"); + // The hard-fail arm panics when a GPU is required. + let panicked = + std::panic::catch_unwind(|| skip_or_fail_gpu_with(true, "a coverage probe")); + assert!( + panicked.is_err(), + "a required GPU must hard-fail a missing adapter" + ); + } + + #[test] + fn shared_slot_host_marking_and_queries() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // `mark_host_context` creates the process context on first use and + // marks it as the host's render device; the three query helpers + // then agree on it. + let marked = GpuContext::mark_host_context(); + assert_eq!(marked, GpuContext::shared().is_some()); + assert_eq!(GpuContext::host_gpu_installed(), marked); + assert_eq!(GpuContext::shared_is_installed(), marked); + } + + #[test] + fn future_executor_drives_pending_futures() { + use std::future::Future; + use std::pin::Pin; + use std::task::{Context, Poll}; + + /// Pending once (forcing the executor's yield arm), then ready; + /// also clones the waker to exercise the no-op vtable. + struct CloneWakerThenYield { + yielded: bool, + } + + impl Future for CloneWakerThenYield { + type Output = u32; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let waker = cx.waker().clone(); + assert!(waker.will_wake(cx.waker())); + drop(waker); + if self.yielded { + Poll::Ready(42) + } else { + self.yielded = true; + Poll::Pending + } + } + } + + assert_eq!( + pollster_block_on(CloneWakerThenYield { yielded: false }), + 42 + ); + // The packing helper is little-endian f32 bytes. + assert_eq!(f32_uniform_bytes(&[1.0f32]), vec![0x00, 0x00, 0x80, 0x3f]); + } + + #[test] + fn frame_from_pixels_for_upload_validates_arguments() { + let mut frame = Frame::new(); + let pod = VideoParamsPod { + width: 2, + height: 2, + ..Default::default() + }; + frame.set_video_params(pod); + frame.allocate(); + let stride = frame.linesize_bytes(); + // Null pointer / non-positive geometry. + assert!(unsafe { frame_from_pixels_for_upload((2, 2), std::ptr::null(), stride) }.is_err()); + assert!( + unsafe { frame_from_pixels_for_upload((0, 2), frame.data.as_ptr(), stride) }.is_err() + ); + assert!( + unsafe { frame_from_pixels_for_upload((2, -1), frame.data.as_ptr(), stride) }.is_err() + ); + // Stride must match the F32 line size. + assert!(unsafe { frame_from_pixels_for_upload((2, 2), frame.data.as_ptr(), 3) }.is_err()); + // The valid shape copies the pixels. + let built = + unsafe { frame_from_pixels_for_upload((2, 2), frame.data.as_ptr(), stride) }.unwrap(); + assert_eq!(built.width, 2); + assert_eq!(built.height, 2); + assert_eq!(built.data.len(), frame.data.len()); + } + + #[test] + fn gpu_context_accessors_and_registry_errors() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ctx) = any_gpu() else { + return; + }; + // Engine-created contexts host a non-CPU adapter and are unused + // until the first texture. + assert!(ctx.is_gpu()); + assert_ne!(ctx.kind(), BackendKind::Cpu); + assert!(!ctx.is_adopted()); + assert!(!ctx.is_used()); + let _ = ctx.is_filterable(); + let (device, queue) = ctx.device_queue(); + assert!(Arc::strong_count(&device) >= 1); + assert!(Arc::strong_count(&queue) >= 1); + + // Invalid create parameters are rejected before any GPU work. + assert_eq!( + ctx.create_texture(0, 4).unwrap_err().code(), + Error::Invalid.code() + ); + assert_eq!( + ctx.create_texture(4, -1).unwrap_err().code(), + Error::Invalid.code() + ); + assert_eq!( + ctx.create_texture_format( + 4, + 4, + 0, + wgpu::TextureFormat::R8Unorm, + wgpu::TextureUsages::TEXTURE_BINDING, + ) + .unwrap_err() + .code(), + Error::Invalid.code() + ); + + let token = ctx.create_texture(4, 3).unwrap(); + assert!(ctx.is_used()); + assert_eq!(ctx.texture_size(token), Some((4, 3))); + assert_eq!( + ctx.texture_format(token), + Some(wgpu::TextureFormat::Rgba32Float) + ); + assert!(ctx.texture_handle(token).is_some()); + assert_eq!(ctx.texture_size(999999), None); + assert_eq!(ctx.texture_format(999999), None); + assert!(!ctx.has_texture(999999)); + + // The placeholder is created once and cached. + let placeholder = ctx.placeholder_texture().unwrap(); + assert_eq!(ctx.placeholder_texture().unwrap(), placeholder); + assert!(ctx.has_texture(placeholder)); + + // Clearing an existing texture succeeds; a missing token reports + // NotFound. + ctx.clear_texture(token).unwrap(); + assert_eq!( + ctx.clear_texture(999999).unwrap_err().code(), + Error::NotFound.code() + ); + + // The trait-object surface used by fakes/hardware import. + let like: &dyn GpuContextLike = ctx.as_ref(); + assert_eq!(like.kind(), ctx.kind()); + assert!(like.as_any().is_some()); + assert!(like.texture_handle(placeholder).is_some()); + } + + #[test] + fn gpu_plane_upload_formats_and_bounds() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ctx) = any_gpu() else { + return; + }; + let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + // Every single-channel format the bpp table accepts (R16Float + // covers the 2-byte arm; R16Unorm requires an optional device + // feature the context does not enable). + let r8 = ctx + .create_texture_format(4, 2, 1, wgpu::TextureFormat::R8Unorm, usage) + .unwrap(); + ctx.upload_plane(r8, &[7u8; 8]).unwrap(); + let r16f = ctx + .create_texture_format(4, 2, 1, wgpu::TextureFormat::R16Float, usage) + .unwrap(); + ctx.upload_plane(r16f, &[0u8; 16]).unwrap(); + let r32f = ctx + .create_texture_format(4, 2, 1, wgpu::TextureFormat::R32Float, usage) + .unwrap(); + ctx.upload_plane(r32f, &[0u8; 32]).unwrap(); + + // A short buffer and an unsupported format are rejected. + assert_eq!( + ctx.upload_plane(r8, &[0u8; 7]).unwrap_err().code(), + Error::Invalid.code() + ); + let rgba = ctx + .create_texture_format(4, 2, 1, wgpu::TextureFormat::Rgba8Unorm, usage) + .unwrap(); + assert_eq!( + ctx.upload_plane(rgba, &[0u8; 32]).unwrap_err().code(), + Error::Invalid.code() + ); + // Missing tokens report NotFound. + assert_eq!( + ctx.upload_plane(999999, &[0u8; 8]).unwrap_err().code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.download(999999).unwrap_err().code(), + Error::NotFound.code() + ); + // Only Rgba32Float/Rgba16Float are readable back. + let err = ctx.download(r8).unwrap_err(); + assert!( + err.to_string().contains("unsupported format"), + "the format error is named: {err}" + ); + } + + #[test] + fn gpu_lut_lifecycle_cache_and_validation() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ctx) = any_gpu() else { + return; + }; + let lut = crate::lut::Lut3d::build(3, [0.0; 3], [1.0; 3], |c| [c[1], c[2], c[0]]); + // Installing twice replaces (and destroys) the previous LUT texture. + ctx.set_display_lut(&lut).unwrap(); + assert!(ctx.has_display_lut()); + ctx.set_display_lut(&lut).unwrap(); + assert!(ctx.has_display_lut()); + + let src = ctx.create_texture(2, 2).unwrap(); + // Two present calls: the first builds the Rgba16Float pipeline, the + // second reuses the cached one. + let presented = ctx.present_texture(src).unwrap(); + let presented2 = ctx.present_texture(src).unwrap(); + assert_ne!(presented, presented2); + + // The caller-keyed cache uploads once per key. + reset_gpu_transfer_counters(); + let first = ctx.apply_color_lut(src, "swap", &lut).unwrap(); + assert_eq!(gpu_transfer_counters(), (1, 0)); + reset_gpu_transfer_counters(); + let again = ctx.apply_color_lut(src, "swap", &lut).unwrap(); + assert_eq!(gpu_transfer_counters(), (0, 0)); + assert_ne!(first, again); + + // Nine more keys exceed the 8-entry cache: the oldest is evicted. + for i in 0..9 { + ctx.apply_color_lut(src, &format!("key-{i}"), &lut).unwrap(); + } + // The original key was evicted and re-uploads. + reset_gpu_transfer_counters(); + ctx.apply_color_lut(src, "swap", &lut).unwrap(); + assert_eq!(gpu_transfer_counters(), (1, 0)); + + // A malformed LUT is rejected before any upload. + let mut malformed = lut.clone(); + malformed.data.truncate(3); + assert_eq!( + ctx.upload_lut(&malformed).unwrap_err().code(), + Error::Invalid.code() + ); + // An unknown source texture is NotFound. + assert_eq!( + ctx.apply_color_lut(999999, "swap", &lut) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + } + + #[test] + fn gpu_upload_download_and_blit_validate_arguments() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ctx) = any_gpu() else { + return; + }; + let token = ctx.create_texture(4, 4).unwrap(); + let other = ctx.create_texture(4, 4).unwrap(); + + let mut frame = Frame::new(); + let pod = VideoParamsPod { + width: 4, + height: 4, + ..Default::default() + }; + frame.set_video_params(pod); + frame.allocate(); + ctx.upload(token, &frame).unwrap(); + + // Non-F32 input. + let mut wrong_format = frame.clone(); + wrong_format.format = PixelFormat::U8; + assert_eq!( + ctx.upload(token, &wrong_format).unwrap_err().code(), + Error::Invalid.code() + ); + // Geometry mismatch with the texture. + let mut wrong_size = Frame::new(); + let small = VideoParamsPod { + width: 2, + height: 4, + ..Default::default() + }; + wrong_size.set_video_params(small); + wrong_size.allocate(); + assert_eq!( + ctx.upload(token, &wrong_size).unwrap_err().code(), + Error::Invalid.code() + ); + // Declared F32 geometry but truncated pixels. + let mut truncated = frame.clone(); + truncated.data.truncate(frame.linesize_bytes() * 4 - 1); + assert_eq!( + ctx.upload(token, &truncated).unwrap_err().code(), + Error::Invalid.code() + ); + // Unknown tokens. + assert_eq!( + ctx.upload(999999, &frame).unwrap_err().code(), + Error::NotFound.code() + ); + + // The trait-object upload/download/blit paths used by callers that + // only know `GpuContextLike`. + let like: &dyn GpuContextLike = ctx.as_ref(); + like.upload(token, &frame).unwrap(); + let out = like.download(token).unwrap(); + assert_eq!(out.data, frame.data); + like.blit(token, other, None).unwrap(); + assert!(like + .blit( + token, + other, + Some(&crate::color::ColorProcessor::pass_through()) + ) + .is_err()); + // Unknown blit endpoints are NotFound. + assert_eq!( + ctx.blit(999999, other, None).unwrap_err().code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.blit(token, 999999, None).unwrap_err().code(), + Error::NotFound.code() + ); + } + + #[test] + fn gpu_compile_and_run_shader_pass() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ctx) = any_gpu() else { + return; + }; + let wgsl = r#" +@group(0) @binding(1) var src_tex: texture_2d; +@group(0) @binding(2) var src_smp: sampler; +@fragment +fn main(@builtin(position) frag: vec4) -> @location(0) vec4 { + return textureLoad(src_tex, vec2(i32(frag.x), i32(frag.y)), 0); +} +"#; + let program = ctx + .compile_shader_pass("test/pass", wgsl, 1, false, false) + .unwrap(); + assert_eq!(program.texture_count, 1); + assert!(!program.has_uniforms); + assert!(!program.filtering); + // The program cache returns the same compiled pass. + let cached = ctx + .compile_shader_pass("test/pass", wgsl, 1, false, false) + .unwrap(); + assert!(Arc::ptr_eq(&program, &cached)); + + let src = ctx.create_texture(2, 2).unwrap(); + let dst = ctx.create_texture(2, 2).unwrap(); + // The input texture count must match the compiled layout. + assert_eq!( + ctx.run_shader_pass(&program, &[], &[], dst) + .unwrap_err() + .code(), + Error::Invalid.code() + ); + // Missing input/destination textures are NotFound. + assert_eq!( + ctx.run_shader_pass(&program, &[], &[999999], dst) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.run_shader_pass(&program, &[], &[src], 999999) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + ctx.run_shader_pass(&program, &[], &[src], dst).unwrap(); + + // A uniform-declaring, filtering pass; an empty uniform block is + // still uploaded as the minimum 16-byte binding. + let with_uniform = r#" +struct U { v: vec4 }; +@group(0) @binding(0) var u: U; +@group(0) @binding(1) var src_tex: texture_2d; +@group(0) @binding(2) var src_smp: sampler; +@fragment +fn main() -> @location(0) vec4 { return u.v; } +"#; + let uniform_program = ctx + .compile_shader_pass("test/uniform", with_uniform, 1, true, true) + .unwrap(); + assert!(uniform_program.has_uniforms); + assert!(uniform_program.filtering); + ctx.run_shader_pass(&uniform_program, &[], &[src], dst) + .unwrap(); + ctx.run_shader_pass(&uniform_program, &[0u8; 16], &[src], dst) + .unwrap(); + + // A pipeline that fails device validation is a fallible result (the + // validation error scope), not a panic: this shader parses but has + // no `main` fragment entry point. + let bad = ctx.compile_shader_pass( + "test/bad", + "@fragment fn not_main() -> @location(0) vec4 { return vec4(1.0); }", + 0, + false, + false, + ); + assert!(bad.is_err(), "a missing entry point must fail the compile"); + } + + #[test] + fn gpu_yuv_and_planar_passes_cover_error_arms() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ctx) = any_gpu() else { + return; + }; + let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + let (w, h) = (4u32, 2u32); + + // Planar pass: R16Float luma + interleaved RG8 chroma. + let y = ctx + .create_texture_format(w as i32, h as i32, 1, wgpu::TextureFormat::R16Float, usage) + .unwrap(); + let uv = ctx + .create_texture_format(w as i32, h as i32, 1, wgpu::TextureFormat::Rg8Unorm, usage) + .unwrap(); + let dst = ctx.create_texture(w as i32, h as i32).unwrap(); + let y_data: Vec = (0..w * h) + .flat_map(|_| half::f16::from_f32(0.5).to_bits().to_le_bytes()) + .collect(); + ctx.upload_plane(y, &y_data).unwrap(); + // upload_plane has no Rg8 entry; write the chroma plane directly + // (neutral 128 = limited-range zero chroma). + let uv_data = vec![128u8; (w * h * 2) as usize]; + ctx.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &ctx.texture_handle(uv).unwrap(), + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &uv_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w * 2), + rows_per_image: None, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + let transform = YuvTransform::bt709_limited(); + // Two runs reuse the cached planar pipeline. + ctx.run_planar_yuv_to_rgb(y, uv, dst, &transform).unwrap(); + ctx.run_planar_yuv_to_rgb(y, uv, dst, &transform).unwrap(); + let out = ctx.download(dst).unwrap(); + assert_eq!(out.data.len(), (w * h) as usize * 16); + assert!( + out.data.iter().any(|&b| b != 0), + "the planar pass writes pixels" + ); + // Missing plane/destination tokens are NotFound. + assert_eq!( + ctx.run_planar_yuv_to_rgb(999999, uv, dst, &transform) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.run_planar_yuv_to_rgb(y, 999999, dst, &transform) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.run_planar_yuv_to_rgb(y, uv, 999999, &transform) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + + // The 3-plane entry point validates every token too. + assert_eq!( + ctx.run_yuv_to_rgb(999999, uv, uv, dst, &transform) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.run_yuv_to_rgb(y, 999999, uv, dst, &transform) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.run_yuv_to_rgb(y, uv, 999999, dst, &transform) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + assert_eq!( + ctx.run_yuv_to_rgb(y, uv, uv, 999999, &transform) + .unwrap_err() + .code(), + Error::NotFound.code() + ); + } + + #[test] + fn display_renderer_gpu_texture_paths() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(ctx) = any_gpu() else { + return; + }; + let mut renderer = DisplayRenderer::new(BackendKind::Cpu); + // The tests inject the context directly; the public accessors must + // report it. + renderer.ctx = Some(ctx.clone()); + assert!(renderer.is_initialized()); + assert_eq!(renderer.backend(), BackendKind::Cpu); + assert_eq!(renderer.context().map(|c| c.kind()), Some(ctx.kind())); + + let pod = VideoParamsPod { + width: 4, + height: 4, + ..Default::default() + }; + let mut frame = Frame::new(); + frame.set_video_params(pod); + frame.allocate(); + frame.data[0] = 0x5a; + + // A pixel-initialized GPU texture uploads through the constructor. + let src = renderer + .create_texture(&pod, Some((frame.data.as_ptr(), frame.linesize_bytes()))) + .unwrap(); + assert!(matches!(src, Texture::Gpu { .. })); + let Texture::Gpu { token, .. } = &src else { + unreachable!() + }; + let token = *token; + assert_eq!(crate::backend::texture_id_of(&src), token as i32); + // A mismatched initializing linesize destroys the new token and + // fails. + assert_eq!( + renderer + .create_texture(&pod, Some((frame.data.as_ptr(), 1))) + .unwrap_err() + .code(), + Error::Invalid.code() + ); + + // upload_texture: the GPU branch requires the frame line size. + let mut target = renderer.create_texture(&pod, None).unwrap(); + unsafe { + renderer.upload_texture(&mut target, frame.data.as_ptr(), frame.linesize_bytes()) + } + .unwrap(); + let _ = + unsafe { renderer.upload_texture(&mut target, frame.data.as_ptr(), 1) }.unwrap_err(); + // download_texture: the stride must match the frame line size. + let mut buf = vec![0u8; frame.linesize_bytes() * 4]; + unsafe { renderer.download_texture(&target, buf.as_mut_ptr(), frame.linesize_bytes()) } + .unwrap(); + assert_eq!(buf[0], 0x5a); + let _ = unsafe { renderer.download_texture(&target, buf.as_mut_ptr(), 1) }.unwrap_err(); + + // GPU→GPU blit through the renderer (same context). + let mut other = renderer.create_texture(&pod, None).unwrap(); + renderer + .blit_color_managed(Some(&target), &mut other, None) + .unwrap(); + + // Planar textures are never uploadable and have no display id. + let y = ctx + .create_texture_format( + 4, + 2, + 1, + wgpu::TextureFormat::R8Unorm, + wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + ) + .unwrap(); + let uv = ctx + .create_texture_format( + 4, + 2, + 1, + wgpu::TextureFormat::Rg8Unorm, + wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + ) + .unwrap(); + let planar = crate::texture::PlanarTexture::new( + ctx.clone(), + crate::texture::PlanarFormat::Nv12, + (4, 2), + (y, uv), + YuvTransform::bt709_limited(), + (1, 1), + ); + let mut planar_texture = Texture::wrap_planar(planar); + assert_eq!(crate::backend::texture_id_of(&planar_texture), 0); + assert!(planar_texture.is_planar()); + assert!(planar_texture.to_frame().is_err()); + let _ = unsafe { renderer.upload_texture(&mut planar_texture, std::ptr::null(), 0) } + .unwrap_err(); + + // Mixed CPU/GPU blits are rejected in both directions. + let cpu_renderer = DisplayRenderer::new(BackendKind::Cpu); + let mut cpu_src = cpu_renderer.create_texture(&pod, None).unwrap(); + assert_eq!( + renderer + .blit_color_managed(Some(&cpu_src), &mut other, None) + .unwrap_err() + .code(), + crate::error::OAKCORE_E_FAILED + ); + assert!(renderer + .blit_color_managed(Some(&target), &mut cpu_src, None) + .is_err()); + + // Cross-backend readback: CPU renderers have no GPU registry. + let mut readback = vec![0u8; frame.linesize_bytes() * 4]; + assert_eq!( + unsafe { + cpu_renderer.download_from_texture( + token as i32, + &pod, + readback.as_mut_ptr(), + frame.linesize_bytes(), + ) + } + .unwrap_err() + .code(), + crate::error::OAKCORE_E_FAILED + ); + // The GPU renderer downloads by id; the stride must be F32 RGBA. + unsafe { + renderer.download_from_texture( + token as i32, + &pod, + readback.as_mut_ptr(), + frame.linesize_bytes(), + ) + } + .unwrap(); + assert_eq!(readback[0], 0x5a); + assert!(unsafe { + renderer.download_from_texture(token as i32, &pod, readback.as_mut_ptr(), 4) + } + .is_err()); + } + + #[test] + fn display_renderer_cpu_pixel_initialization() { + let renderer = DisplayRenderer::new(BackendKind::Cpu); + let pod = VideoParamsPod { + width: 2, + height: 2, + ..Default::default() + }; + let mut frame = Frame::new(); + frame.set_video_params(pod); + frame.allocate(); + frame.data[3] = 0x33; + // CPU textures accept pixel data with a matching line size. + let texture = renderer + .create_texture(&pod, Some((frame.data.as_ptr(), frame.linesize_bytes()))) + .unwrap(); + let Texture::Cpu(cpu) = &texture else { + unreachable!() + }; + assert_eq!(cpu.data[3], 0x33); + // A mismatched line size is rejected (nothing was allocated). + assert_eq!( + renderer + .create_texture(&pod, Some((frame.data.as_ptr(), 3))) + .unwrap_err() + .code(), + Error::Invalid.code() + ); + } + + #[test] + fn display_bit_depth_from_user_config_reads_the_store() { + // The store defaults to 10-bit when the key is missing; the call + // must never panic and always resolve a depth. + let depth = DisplayBitDepth::from_user_config(); + assert!(matches!( + depth, + DisplayBitDepth::Bit8 | DisplayBitDepth::Bit10 + )); + assert_eq!( + depth.to_config_string(), + if depth == DisplayBitDepth::Bit8 { + "8" + } else { + "10" + } + ); + } + + /// A minimal trait-only context: the default `as_any`/`texture_handle` + /// methods must return `None` (callers then fall back to CPU delivery). + struct FakeContext; + + impl GpuContextLike for FakeContext { + fn kind(&self) -> BackendKind { + BackendKind::Cpu + } + + fn destroy_texture(&self, _token: u64) {} + + fn upload(&self, _token: u64, _frame: &Frame) -> Result<()> { + Ok(()) + } + + fn download(&self, _token: u64) -> Result { + Ok(Frame::dummy()) + } + + fn blit( + &self, + _src: u64, + _dst: u64, + _processor: Option<&crate::color::ColorProcessor>, + ) -> Result<()> { + Ok(()) + } + } + + #[test] + fn trait_only_context_uses_the_default_downcast_hooks() { + let fake = FakeContext; + assert_eq!(fake.kind(), BackendKind::Cpu); + assert!(fake.as_any().is_none()); + assert!(fake.texture_handle(1).is_none()); + let texture = Texture::gpu(Arc::new(FakeContext), 7, 2, 2, PixelFormat::F32); + assert_eq!(texture_id_of(&texture), 7); + assert!(format!("{texture:?}").contains("Texture::Gpu")); + } + + #[test] + fn display_renderer_init_uses_the_shared_context_for_the_user_backend() { + let _guard = GPU_COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // A renderer whose backend equals the user's configured choice + // adopts the process-wide shared context (one device per process). + let kind = BackendKind::from_user_config(); + let mut renderer = DisplayRenderer::new(kind); + let result = renderer.init(std::ptr::null_mut()); + assert_eq!( + result.is_ok(), + GpuContext::shared().is_some(), + "init follows the shared slot" + ); + assert_eq!(renderer.is_initialized(), result.is_ok()); + } } diff --git a/crates/oak-core/src/color.rs b/crates/oak-core/src/color.rs index 39790333d..8bf93c540 100644 --- a/crates/oak-core/src/color.rs +++ b/crates/oak-core/src/color.rs @@ -765,6 +765,22 @@ mod tests { CONFIG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } + /// A display-class ICC profile from the usual system locations, if any + /// (macOS ColorSync, Linux colord/ghostscript, Debian/Ubuntu + /// `icc-profiles-free` — the CI runner installs the latter). + fn system_icc() -> Option<&'static str> { + [ + "/System/Library/ColorSync/Profiles/sRGB Profile.icc", + "/System/Library/ColorSync/Profiles/Display P3.icc", + "/usr/share/color/icc/colord/sRGB.icc", + "/usr/share/color/icc/ghostscript/srgb.icc", + "/usr/share/color/icc/sRGB.icc", + "/usr/local/share/color/icc/colord/sRGB.icc", + ] + .into_iter() + .find(|p| std::path::Path::new(p).exists()) + } + #[test] fn lut_extensions_case_insensitive_and_dot_optional() { assert!(is_supported_lut_extension("cube")); @@ -801,7 +817,7 @@ mod tests { if set_up_default_config().is_err() { return; } - let valid = ColorProcessor::create("scene_linear", "sdr-video", Direction::Normal) + let valid = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal) .and_then(|p| p.is_valid().then_some(p)); if let Some(valid) = valid { let mut f = Frame::new(); @@ -893,6 +909,17 @@ mod tests { let id = display_transform(&display, &view); assert!(id.is_some()); assert!(!id.unwrap().is_empty()); + // Querying a later view iterates past the non-matching ones + // (the loop's pass-through arm); an unknown view is Ok(None). + let last = config + .view_by_reference_space( + ocio_rs::SearchReferenceSpaceType::Scene, + &display, + n - 1, + ) + .unwrap(); + assert!(display_transform_result(&display, &last).is_ok()); + assert_eq!(display_transform_result(&display, "no-such-view").unwrap(), None); } assert!(display_transform("no-such-display", "x").is_none()); } @@ -942,15 +969,9 @@ mod tests { if set_up_default_config().is_err() { return; } - // A display-class ICC is required; the macOS system profiles always - // have one, CI Linux/Windows runners may not — skip then. - let icc = [ - "/System/Library/ColorSync/Profiles/sRGB Profile.icc", - "/System/Library/ColorSync/Profiles/Display P3.icc", - ] - .into_iter() - .find(|p| std::path::Path::new(p).exists()); - let Some(icc) = icc else { + // A display-class ICC is required; the usual system profile + // locations are probed (CI runners may have none — skip then). + let Some(icc) = system_icc() else { eprintln!("no system ICC profile; skipping"); return; }; @@ -986,18 +1007,8 @@ mod tests { if set_up_default_config().is_err() { return; } - // Any display-class ICC; probe the usual macOS + Linux system profile - // locations (CI runners may have none — skip then). - let icc = [ - "/System/Library/ColorSync/Profiles/sRGB Profile.icc", - "/System/Library/ColorSync/Profiles/Display P3.icc", - "/usr/share/color/icc/colord/sRGB.icc", - "/usr/share/color/icc/ghostscript/srgb.icc", - "/usr/local/share/color/icc/colord/sRGB.icc", - ] - .into_iter() - .find(|p| std::path::Path::new(p).exists()); - let Some(icc) = icc else { + // Any display-class ICC (CI runners may have none — skip then). + let Some(icc) = system_icc() else { eprintln!("no system ICC profile; skipping"); return; }; @@ -1040,20 +1051,27 @@ mod tests { /// The exact chain the viewers use (BGRA8, display-class ICC from /// `OAK_DISPLAY_ICC`): a mid-grey frame must NOT collapse to black — - /// the viewer-black-screen regression guard. Skipped without the env - /// var (point it at the display profile under investigation); an empty - /// value is treated as unset, same as `displayicc::env_override_icc`. + /// the viewer-black-screen regression guard. Falls back to a system + /// profile when the env var is unset (point it at the display profile + /// under investigation to override); skipped only when neither exists. + /// An empty env value is treated as unset, same as + /// `displayicc::env_override_icc`. #[test] fn display_icc_bgra8_never_outputs_black() { let _lock = config_lock(); if set_up_default_config().is_err() { return; } - let icc = std::env::var("OAK_DISPLAY_ICC").unwrap_or_default(); - if icc.is_empty() { - eprintln!("OAK_DISPLAY_ICC unset or empty; skipping"); - return; - } + let env_icc = std::env::var("OAK_DISPLAY_ICC").unwrap_or_default(); + let icc = if env_icc.is_empty() { + let Some(system) = system_icc() else { + eprintln!("OAK_DISPLAY_ICC unset and no system ICC profile; skipping"); + return; + }; + system.to_string() + } else { + env_icc + }; let p = ColorProcessor::create_display_icc_bgra8("sRGB Encoded Rec.709 (sRGB)", &icc) .expect("handle always returned"); assert!(p.is_valid(), "BGRA8 ICC processor builds from {icc}"); @@ -1147,22 +1165,24 @@ mod tests { fn grading_primary_and_from_processor() { let _lock = config_lock(); if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping grading-primary/from-processor assertions"); return; } - if let Some(p) = ColorProcessor::create_grading_primary(GradingStyle::Log) { - // A grading-primary processor is valid and converts. - if p.is_valid() { - let out = p.convert_color([0.18, 0.5, 0.7, 1.0]); - assert!(out.iter().all(|v| v.is_finite())); - } - } - if let Some(config) = default_config() { - if let Ok(proc) = config.processor("ACEScg", "sRGB Encoded Rec.709 (sRGB)") { - let p = ColorProcessor::from_processor(proc); - assert!(p.is_valid()); - assert!(!p.cache_id().is_empty(), "OCIO cache id present"); - } - } + // A grading-primary processor is valid and converts on the live + // config (the shader test below relies on the same construction). + let p = ColorProcessor::create_grading_primary(GradingStyle::Log) + .expect("grading-primary processor on a live config"); + assert!(p.is_valid(), "grading-primary processor must be valid"); + let out = p.convert_color([0.18, 0.5, 0.7, 1.0]); + assert!(out.iter().all(|v| v.is_finite())); + + let config = default_config().expect("config set up"); + let proc = config + .processor("ACEScg", "sRGB Encoded Rec.709 (sRGB)") + .expect("ACEScg→sRGB Encoded processor on the default config"); + let p = ColorProcessor::from_processor(proc); + assert!(p.is_valid()); + assert!(!p.cache_id().is_empty(), "OCIO cache id present"); } #[test] @@ -1249,4 +1269,599 @@ mod tests { "analytic processor still generates" ); } + + // ---- Branch-coverage fill-ins ------------------------------------------ + + #[test] + fn rb_swap_matrix_builds_from_ocio() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping"); + return; + } + assert!(rb_swap_matrix().is_some(), "matrix transform available"); + } + + #[test] + fn pass_through_processor_accessors_and_conversions() { + let p = ColorProcessor::pass_through(); + assert!(p.processor().is_none()); + assert_eq!(p.cache_id(), "", "pass-through has no cache id"); + // BGRA8 and F32 conversions are no-ops without a CPU processor. + let mut data = [1u8, 2, 3, 4]; + p.convert_bgra8(&mut data, 1).unwrap(); + assert_eq!(data, [1, 2, 3, 4]); + let mut samples = [0.25f32, 0.5, 0.75, 1.0]; + p.convert_f32_rgba(&mut samples, 1).unwrap(); + assert_eq!(samples, [0.25, 0.5, 0.75, 1.0]); + // Negative/zero pixel counts stay harmless on the no-op path too. + p.convert_bgra8(&mut data, -3).unwrap(); + } + + #[test] + fn create_lut_on_config_and_inverse_direction() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping"); + return; + } + let config = default_config().expect("config set up"); + let lut = "TITLE oak create_lut_on test\nLUT_1D_SIZE 2\n0.0 0.0 0.0\n1.0 1.0 1.0\n"; + let path = std::env::temp_dir().join(format!("oak-color-lut-on-{}.cube", std::process::id())); + std::fs::write(&path, lut).unwrap(); + let path = path.to_string_lossy().into_owned(); + + let fwd = ColorProcessor::create_lut_on(&config, &path, Direction::Normal) + .expect("processor handle always returned"); + assert!(fwd.is_valid(), "readable LUT on a specific config"); + // Direction::Inverse exercises the inverse mapping (`to_ocio`) on + // the transform and the processor. + let inv = ColorProcessor::create_lut_on(&config, &path, Direction::Inverse) + .expect("processor handle always returned"); + assert!(inv.is_valid(), "inverse LUT processor"); + // create_lut (default config) with the inverse direction too. + let inv_default = ColorProcessor::create_lut(&path, Direction::Inverse) + .expect("processor handle always returned"); + assert!(inv_default.is_valid()); + + // An unreadable LUT yields a pass-through handle, not None. + let missing = ColorProcessor::create_lut_on(&config, "/nonexistent/oak.cube", Direction::Normal) + .expect("processor handle always returned"); + assert!(!missing.is_valid(), "unreadable LUT → pass-through"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn valid_processor_accessor_and_cache_id() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping processor accessor assertions"); + return; + } + let config = default_config().expect("config set up"); + let proc = config + .processor("ACEScg", "sRGB Encoded Rec.709 (sRGB)") + .expect("ACEScg→sRGB Encoded processor on the default config"); + let p = ColorProcessor::from_processor(proc); + assert!(p.processor().is_some(), "valid processor exposes its handle"); + assert!(!p.cache_id().is_empty()); + } + + #[test] + fn convert_frame_rejects_non_f32_and_unaligned_buffers() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping"); + return; + } + let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal) + .expect("handle always returned"); + if !p.is_valid() { + eprintln!("processor unavailable in this config; skipping"); + return; + } + // A valid processor only accepts the F32 pipeline format. + let mut f = Frame::new(); + f.format = PixelFormat::U8; + assert_eq!( + p.convert_frame(&mut f).unwrap_err().code(), + crate::error::OAKCORE_E_INVALID + ); + // F32 but a byte length that is not a multiple of 4. + let mut f = Frame::new(); + f.format = PixelFormat::F32; + f.data = vec![0u8; 3]; + assert!(p.convert_frame(&mut f).is_err(), "unaligned buffer rejected"); + // A well-formed F32 buffer converts. + let mut f = Frame::new(); + f.width = 1; + f.height = 1; + f.channels = 4; + f.format = PixelFormat::F32; + f.data = vec![0u8; 16]; + p.convert_frame(&mut f).unwrap(); + } + + #[test] + fn display_icc_bgra8_and_xyz_bgra8_chains() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping"); + return; + } + let Some(icc) = system_icc() else { + eprintln!("no system ICC profile; skipping"); + return; + }; + let p = ColorProcessor::create_display_icc_bgra8("sRGB Encoded Rec.709 (sRGB)", icc) + .expect("handle always returned"); + assert!(p.is_valid(), "BGRA8 ICC chain builds from {icc}"); + let mut data: Vec = vec![128, 128, 128, 255, 0, 0, 191, 255]; + assert!(p.convert_bgra8(&mut data, 2).is_ok()); + assert_eq!(data[3], 255, "alpha preserved"); + assert!(data[..3].iter().any(|&b| b != 0), "grey survives"); + // A short buffer is rejected before converting anything. + let mut short = vec![0u8; 3]; + assert_eq!( + p.convert_bgra8(&mut short, 2).unwrap_err().code(), + crate::error::OAKCORE_E_INVALID + ); + // Zero/negative counts clamp to nothing to convert. + let mut unchanged = vec![7u8; 4]; + let _ = p.convert_bgra8(&mut unchanged, 0); + let _ = p.convert_bgra8(&mut unchanged, -3); + // The F32 entry point on the same chain. + let mut samples: Vec = vec![0.5, 0.5, 0.5, 1.0]; + assert!(p.convert_f32_rgba(&mut samples, 1).is_ok()); + assert!((samples[3] - 1.0).abs() < 1e-3, "alpha preserved"); + // The XYZ BGRA8 wrapper (both R/B swizzles baked in). + let xyz = ColorProcessor::create_display_icc_xyz_bgra8(icc).expect("handle"); + assert!(xyz.is_valid(), "XYZ BGRA8 ICC chain builds from {icc}"); + } + + /// Captures the process-wide pipeline color settings and restores them + /// on drop, so a panicking assertion cannot leak the legacy setting + /// into the tests that run after it. + struct PipelineColorGuard { + working: crate::colormath::WorkingColorSpace, + output: crate::colormath::OutputColorSpec, + } + + impl PipelineColorGuard { + fn capture() -> Self { + Self { + working: pipeline_working_space(), + output: pipeline_output_spec(), + } + } + + fn restore(&mut self) { + set_pipeline_color_settings(self.working, self.output); + } + } + + impl Drop for PipelineColorGuard { + fn drop(&mut self) { + self.restore(); + } + } + + #[test] + fn pipeline_working_ofx_name_legacy_mode() { + use crate::colormath::WorkingColorSpace; + // The pipeline settings are process-global: take the config lock + // like the other tests that touch global color state. + let _lock = config_lock(); + let mut restore = PipelineColorGuard::capture(); + set_pipeline_color_settings(WorkingColorSpace::SrgbLegacy, restore.output); + assert_eq!(pipeline_working_ofx_name(), "sRGB"); + restore.restore(); + let expected = match restore.working { + WorkingColorSpace::AcesCg => "ACEScg", + WorkingColorSpace::SrgbLegacy => "sRGB", + }; + assert_eq!(pipeline_working_ofx_name(), expected, "settings restored"); + } + + #[test] + fn config_path_env_and_bundled_fallback() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping"); + return; + } + let saved = std::env::var_os("OCIO"); + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("OCIO", v), + None => std::env::remove_var("OCIO"), + } + } + } + let _env_guard = EnvGuard(saved); + + std::env::set_var("OCIO", "/tmp/oak-config-path-test.ocio"); + assert_eq!( + config_path().as_deref(), + Some("/tmp/oak-config-path-test.ocio"), + "a non-empty $OCIO wins" + ); + // An empty $OCIO is treated as unset and falls back to the bundled + // extraction location (a config must exist for that branch). + std::env::set_var("OCIO", ""); + let fallback = config_path().expect("bundled fallback when a config exists"); + assert!(fallback.ends_with("ocioconf/config.ocio"), "got {fallback}"); + } + + #[test] + fn set_up_default_config_from_bad_path_errors_and_keeps_config() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + return; + } + let before = default_config(); + let err = set_up_default_config_from(Some("/nonexistent/oak-config.ocio")); + assert!(err.is_err(), "a bad config path is a load error"); + assert!( + default_config().is_some(), + "the previous default config survives a failed load" + ); + assert_eq!( + default_config().map(|c| c.cache_id()), + before.map(|c| c.cache_id()) + ); + } + + #[test] + fn display_transform_scene_linear_role_fallback() { + let _lock = config_lock(); + // A minimal config with a scene_linear role but no reference / + // aces_interchange bindings: the last-resort fallback chain. + let cfg_text = r#" +ocio_profile_version: 2 +name: oak-fallback-test +search_path: "" +roles: + scene_linear: Raw +displays: + oakdisplay: + - ! {name: oakview, colorspace: Raw} +colorspaces: + - ! + name: Raw + family: "" + isdata: false + allocation: uniform +"#; + let Ok(cfg) = ocio_rs::Config::from_stream(cfg_text) else { + eprintln!("OCIO config-from-stream unavailable; skipping"); + return; + }; + // The fallback chain only runs when the first three lookups fail. + assert!(cfg + .color_space("reference") + .and_then(|cs| cs.name()) + .filter(|s| !s.is_empty()) + .is_none()); + assert!(cfg + .role_color_space("reference") + .filter(|s| !s.is_empty()) + .is_none()); + assert!(cfg + .role_color_space("aces_interchange") + .filter(|s| !s.is_empty()) + .is_none()); + assert_eq!(cfg.role_color_space("scene_linear").as_deref(), Some("Raw")); + let saved = default_config(); + *DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = + Some(std::sync::Arc::new(SafeConfig(cfg))); + let found = display_transform_result("oakdisplay", "oakview"); + let unknown_view = display_transform_result("oakdisplay", "nope"); + let unknown_display = display_transform_result("nope", "oakview"); + *DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = saved; + + assert!( + found.is_ok(), + "scene_linear fallback resolves the reference space: {found:?}" + ); + assert_eq!(unknown_view.unwrap(), None); + assert_eq!(unknown_display.unwrap(), None); + } + + // ---- Additional branch coverage: no-config and explicit-config paths ---- + + /// Temporarily clear the process-wide default config and restore it on + /// drop (used by the no-config tests below; the caller must hold + /// `config_lock`). + struct DefaultConfigGuard(Option>); + + impl DefaultConfigGuard { + fn clear() -> Self { + let saved = default_config(); + *DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None; + Self(saved) + } + } + + impl Drop for DefaultConfigGuard { + fn drop(&mut self) { + *DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = self.0.take(); + } + } + + /// Temporarily set `$OCIO`, restoring the previous value on drop. + struct OcioEnvGuard(Option); + + impl OcioEnvGuard { + fn set(value: &str) -> Self { + let saved = std::env::var_os("OCIO"); + std::env::set_var("OCIO", value); + Self(saved) + } + } + + impl Drop for OcioEnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("OCIO", v), + None => std::env::remove_var("OCIO"), + } + } + } + + /// Every `default_config()?` guard's `None` arm: without a process + /// default config each factory returns `None` instead of panicking, + /// and `display_transform_result` reports the missing-config state. + #[test] + fn factories_without_a_default_config_return_none() { + let _lock = config_lock(); + let _env = OcioEnvGuard::set(""); + let _guard = DefaultConfigGuard::clear(); + + assert!(ColorProcessor::create("scene_linear", "sdr-video", Direction::Normal).is_none()); + assert!(ColorProcessor::create_lut("/tmp/oak-none.cube", Direction::Normal).is_none()); + assert!(ColorProcessor::create_grading_primary(GradingStyle::Lin).is_none()); + assert!(ColorProcessor::create_grading_primary(GradingStyle::Log).is_none()); + assert!(ColorProcessor::create_display_icc("scene_linear", "/tmp/oak-none.icc").is_none()); + assert!( + ColorProcessor::create_display_icc_bgra8("scene_linear", "/tmp/oak-none.icc").is_none() + ); + assert!(ColorProcessor::create_display_icc_xyz("/tmp/oak-none.icc").is_none()); + assert!(ColorProcessor::create_display_icc_xyz_bgra8("/tmp/oak-none.icc").is_none()); + assert!(ocio_function_shader("oak_fn", "a", "b").is_none()); + assert!(grading_primary_function_shader(GradingStyle::Lin).is_none()); + assert!(grading_primary_function_shader(GradingStyle::Log).is_none()); + assert_eq!( + display_transform_result("display", "view") + .unwrap_err() + .code(), + crate::error::OAKCORE_E_STATE + ); + assert!(display_transform("display", "view").is_none()); + assert!(config_path().is_none(), "no config and empty $OCIO"); + } + + /// `set_up_default_config_from(Some(path))` loads an explicit config + /// file and installs it as the process default (the project-properties + /// OCIO override path), replacing the previous default. + #[test] + fn set_up_default_config_from_explicit_file_replaces_default() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no bundled OCIO config; skipping"); + return; + } + let path = + std::env::temp_dir().join(format!("oak-color-explicit-{}.ocio", std::process::id())); + let cfg_text = r#" +ocio_profile_version: 2 +name: oak-explicit-test +search_path: "" +roles: + scene_linear: Raw + default: Raw +displays: + oakdisplay: + - ! {name: oakview, colorspace: Raw} +colorspaces: + - ! + name: Raw + family: "" + isdata: false + allocation: uniform +"#; + std::fs::write(&path, cfg_text).unwrap(); + let result = set_up_default_config_from(Some(path.to_string_lossy().as_ref())); + let installed_name = default_config().and_then(|c| c.name()); + let _ = std::fs::remove_file(&path); + // Restore the bundled default config installed above. + let restored = set_up_default_config_from(None); + + assert!(result.is_ok(), "an explicit valid config loads: {result:?}"); + assert_eq!(installed_name.as_deref(), Some("oak-explicit-test")); + assert!( + restored.is_ok(), + "the bundled config restores: {restored:?}" + ); + } + + /// The XYZ display chain's build failure surfaces as `None` (not a + /// pass-through), so the caller can fall back to the sRGB chain: a + /// config that cannot produce the processor (unreadable ICC file) + /// yields `Some(invalid)` from the classic builder but `None` from + /// the XYZ wrapper. + #[test] + fn display_icc_xyz_returns_none_when_chain_is_invalid() { + let _lock = config_lock(); + let cfg_text = r#" +ocio_profile_version: 2 +name: oak-xyz-test +search_path: "" +roles: + scene_linear: Raw + cie_xyz_d65_interchange: Raw +displays: + oakdisplay: + - ! {name: oakview, colorspace: Raw} +colorspaces: + - ! + name: Raw + family: "" + isdata: false + allocation: uniform + - ! + name: Linear Rec.709 (sRGB) + family: "" + isdata: false + allocation: uniform +"#; + let Ok(cfg) = ocio_rs::Config::from_stream(cfg_text) else { + eprintln!("OCIO config-from-stream unavailable; skipping"); + return; + }; + // An unreadable ICC file must fail processor creation but not the + // transform-chain assembly. + let icc = std::env::temp_dir().join(format!("oak-xyz-junk-{}.icc", std::process::id())); + std::fs::write(&icc, b"this is not an ICC profile").unwrap(); + let icc = icc.to_string_lossy().into_owned(); + + let _guard = DefaultConfigGuard::clear(); + *DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = + Some(std::sync::Arc::new(SafeConfig(cfg))); + + let classic = + ColorProcessor::create_display_icc("cie_xyz_d65_interchange", &icc).expect("handle"); + let classic_bgra8 = + ColorProcessor::create_display_icc_bgra8("cie_xyz_d65_interchange", &icc) + .expect("handle"); + let xyz = ColorProcessor::create_display_icc_xyz(&icc); + let xyz_bgra8 = ColorProcessor::create_display_icc_xyz_bgra8(&icc); + + assert!( + !classic.is_valid(), + "the ICC leg fails against a junk profile (non-fatal pass-through)" + ); + assert!(!classic_bgra8.is_valid()); + assert!( + xyz.is_none(), + "an invalid chain must surface as None for the XYZ wrapper" + ); + assert!(xyz_bgra8.is_none()); + let _ = std::fs::remove_file(&icc); + } + + /// A config whose display/view exist but whose roles do not resolve + /// makes `display_transform_result` report `Error::State` (the + /// `ok_or` arm of the role fallback chain). + #[test] + fn display_transform_result_errors_when_no_reference_role_resolves() { + let _lock = config_lock(); + let cfg_text = r#" +ocio_profile_version: 2 +name: oak-no-roles-test +search_path: "" +displays: + oakdisplay: + - ! {name: oakview, colorspace: Raw} +colorspaces: + - ! + name: Raw + family: "" + isdata: false + allocation: uniform +"#; + let Ok(cfg) = ocio_rs::Config::from_stream(cfg_text) else { + eprintln!("OCIO config-from-stream unavailable; skipping"); + return; + }; + let _guard = DefaultConfigGuard::clear(); + *DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = + Some(std::sync::Arc::new(SafeConfig(cfg))); + + let err = display_transform_result("oakdisplay", "oakview") + .expect_err("no reference role resolves to a source space"); + assert_eq!(err.code(), crate::error::OAKCORE_E_STATE); + assert!(display_transform("oakdisplay", "oakview").is_none()); + } + + /// `set_up_default_config` honours `$OCIO` (non-empty → that file; + /// empty → the bundled config) and swaps the process default. + #[test] + fn set_up_default_config_honours_the_ocio_environment() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no bundled OCIO config; skipping"); + return; + } + let path = std::env::temp_dir().join(format!("oak-color-env-{}.ocio", std::process::id())); + let cfg_text = r#" +ocio_profile_version: 2 +name: oak-env-test +search_path: "" +roles: + scene_linear: Raw + default: Raw +displays: + oakdisplay: + - ! {name: oakview, colorspace: Raw} +colorspaces: + - ! + name: Raw + family: "" + isdata: false + allocation: uniform +"#; + std::fs::write(&path, cfg_text).unwrap(); + let path_str = path.to_string_lossy().into_owned(); + + let loaded = { + let _env = OcioEnvGuard::set(&path_str); + set_up_default_config() + }; + let installed_name = default_config().and_then(|c| c.name()); + // An empty $OCIO is treated as unset: the bundled config loads. + let bundled = { + let _env = OcioEnvGuard::set(""); + set_up_default_config() + }; + let _ = std::fs::remove_file(&path); + + assert!(loaded.is_ok(), "a non-empty $OCIO loads: {loaded:?}"); + assert_eq!(installed_name.as_deref(), Some("oak-env-test")); + assert!(bundled.is_ok(), "an empty $OCIO falls back: {bundled:?}"); + } + + /// A valid processor rejects a short F32 buffer (the `try_apply` + /// error mapping) and the `convert_bgra8` entry point guards its + /// staging buffer length. + #[test] + fn conversion_error_arms_from_buffer_size_mismatches() { + let _lock = config_lock(); + if set_up_default_config().is_err() { + eprintln!("no default OCIO config; skipping"); + return; + } + let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal) + .expect("handle always returned"); + if !p.is_valid() { + eprintln!("processor unavailable in this config; skipping"); + return; + } + // Fewer samples than pixels * 4: the OCIO apply fails and is + // mapped to a named error. + let mut short = [0f32; 4]; + let err = p.convert_f32_rgba(&mut short, 2).unwrap_err(); + assert!( + err.to_string().contains("OCIO f32 apply"), + "the apply error is named: {err}" + ); + // BGRA8: the staging buffer must cover pixels * 4 bytes. + let mut bytes = [9u8; 4]; + assert_eq!( + p.convert_bgra8(&mut bytes, 2).unwrap_err().code(), + crate::error::OAKCORE_E_INVALID + ); + } } diff --git a/crates/oak-core/src/videoparams.rs b/crates/oak-core/src/videoparams.rs index 4ca761aae..dff5fcde3 100644 --- a/crates/oak-core/src/videoparams.rs +++ b/crates/oak-core/src/videoparams.rs @@ -2326,4 +2326,243 @@ mod tests { assert_eq!(vp.time_in_timebase_units(n, d), Some(expected)); } } + + // ---- Branch-coverage fill-ins ------------------------------------------ + + #[test] + fn new_with_time_base_null_par_defaults_to_square() { + // C++ validate_pixel_aspect_ratio(): a null PAR falls back to 1/1. + let vp = VideoParams::new_with_time_base( + 640, + 480, + 1, + 25, + PixelFormat::U16, + 3, + 0, + 7, + 2, + 1, + ); + assert_eq!(vp.pixel_aspect_ratio(), (1, 1)); + // The BottomFirst interlacing code passes through the ctor too. + assert_eq!(vp.interlacing(), Interlacing::BottomFirst); + assert_eq!(vp.time_base(), (1, 25)); + } + + #[test] + fn square_pixel_width_with_zero_par_denominator() { + // A (x, 0) PAR is a NaN rational no setter can produce (setters + // validate); the defensive denominator check must return the raw + // width instead of propagating NaN. + let mut vp = default_vp(); + vp.pixel_aspect_ratio = (4, 0); + assert_eq!(vp.square_pixel_width(), 1920); + } + + #[test] + fn load_xml_bad_values_error_for_interlacing_and_colorrange() { + let mut vp = VideoParams::new(); + // stoi_field failures inside these two arms propagate as errors. + assert!(vp + .load_xml("notanumber") + .is_err()); + assert!(vp + .load_xml("notanumber") + .is_err()); + } + + #[test] + fn pixel_format_code_round_trip_all_arms() { + for (code, expected) in [ + (-1, PixelFormat::Invalid), + (0, PixelFormat::U8), + (1, PixelFormat::U10), + (2, PixelFormat::U16), + (3, PixelFormat::F16), + (4, PixelFormat::F32), + (5, PixelFormat::Count), + (6, PixelFormat::Invalid), + (99, PixelFormat::Invalid), + ] { + assert_eq!(pf_from_code(code), expected, "code {code}"); + } + for pf in [ + PixelFormat::U8, + PixelFormat::U10, + PixelFormat::U16, + PixelFormat::F16, + PixelFormat::F32, + ] { + assert_eq!(pf_from_code(pf_code(pf)), pf); + } + } + + #[test] + fn enum_i32_mapping_all_arms() { + assert_eq!(interlacing_from_i32(1), Interlacing::TopFirst); + assert_eq!(interlacing_from_i32(2), Interlacing::BottomFirst); + assert_eq!(interlacing_from_i32(3), Interlacing::None); + assert_eq!(video_type_from_i32(1), VideoType::Still); + assert_eq!(video_type_from_i32(2), VideoType::ImageSequence); + assert_eq!(video_type_from_i32(9), VideoType::Video); + assert_eq!(color_range_from_i32(1), ColorRange::Full); + assert_eq!(color_range_from_i32(0), ColorRange::Limited); + } + + #[test] + fn save_xml_escapes_reserved_characters() { + let mut vp = default_vp(); + vp.set_colorspace("a&bd"); + let xml = vp.save_xml().unwrap(); + assert!( + xml.contains("a&b<c>d"), + "escaped text: {xml}" + ); + let mut loaded = VideoParams::new(); + loaded.load_xml(&xml).unwrap(); + assert_eq!(loaded.colorspace(), "a&bd"); + } + + #[test] + fn gcd_and_rational_helpers_sign_arms() { + // Negative second operand exercises the `b = -b` arm. + assert_eq!(i64_gcd(4, -6), 2); + assert_eq!(i64_gcd(-4, -6), 2); + assert_eq!(i64_gcd(0, 5), 5); + assert_eq!(i64_gcd(7, 0), 7); + assert_eq!(i64_gcd(0, 0), 0); + // A negative numerator flows through the reduce path. + assert_eq!(make_rational(-6, 4), (-3, 2)); + } + + #[test] + fn rational_flipped_sign_and_degenerate_inputs() { + // Negative denominator after the swap is sign-fixed. + assert_eq!(rational_flipped((3, -2)), (-2, 3)); + assert_eq!(rational_flipped((-3, 2)), (-2, 3)); + // Null rational is returned untouched. + assert_eq!(rational_flipped((0, 5)), (0, 5)); + assert_eq!(rational_flipped((0, 0)), (0, 0)); + // Non-null numerator with a zero denominator swaps to 0/1. + assert_eq!(rational_flipped((1, 0)), (0, 1)); + } + + #[test] + fn xml_nested_field_text_and_attributes() { + let mut vp = VideoParams::new(); + // Nested elements inside a known field: depth-1 text only. + vp.load_xml("9640") + .unwrap(); + assert_eq!(vp.width(), 640); + // Whitespace after the tag name, around '=', and single-quoted values. + vp.load_xml("480") + .unwrap(); + assert_eq!(vp.height(), 480); + // Entities (and plain runs) inside attribute values are validated. + vp.load_xml("2") + .unwrap(); + assert_eq!(vp.depth(), 2); + // Unknown entities in an attribute value are parse errors. + assert!(vp + .load_xml("2") + .is_err()); + } + + #[test] + fn xml_doctype_paths() { + let mut vp = VideoParams::new(); + vp.load_xml("1") + .unwrap(); + assert_eq!(vp.width(), 1); + // An internal subset keeps the '>' inside the brackets from ending + // the declaration. + vp.load_xml( + " ]>2", + ) + .unwrap(); + assert_eq!(vp.width(), 2); + // A closing bracket with no open one saturates at zero. + vp.load_xml("3") + .unwrap(); + assert_eq!(vp.width(), 3); + // An unterminated declaration is a parse error. + assert!(vp.load_xml("< >").is_err()); + // Unterminated start tag (no '>'). + assert!(vp.load_xml("'. + assert!(vp.load_xml("").is_err()); + // Attribute without '='. + assert!(vp + .load_xml("1") + .is_err()); + // Attribute value without an opening quote. + assert!(vp + .load_xml("1") + .is_err()); + // Unterminated attribute value. + assert!(vp + .load_xml("1") + .is_err()); + // Junk after an end-element name. + assert!(vp + .load_xml("1") + .is_err()); + // An end tag that never closes errors too. + assert!(vp.load_xml("' is fine. + vp.load_xml("5") + .unwrap(); + assert_eq!(vp.width(), 5); + } + + #[test] + fn xml_numeric_entities() { + let mut vp = VideoParams::new(); + vp.load_xml("AB😀") + .unwrap(); + assert_eq!(vp.colorspace(), "AB\u{1F600}"); + // Invalid hex digits / out-of-range code points are parse errors. + assert!(vp + .load_xml("&#xZZ;") + .is_err()); + assert!(vp + .load_xml("�") + .is_err()); + assert!(vp + .load_xml("�") + .is_err()); + } + + #[test] + fn xml_cursor_handles_unclosed_event_streams() { + // Defensive: a truncated event list reaches EndDocument inside + // read_element_text. + let mut cur = XmlCursor::new(vec![XmlEvent::StartElement("a".to_string())]); + assert!(cur.next_start_element()); + assert_eq!(cur.read_element_text(), ""); + // ... and inside skip_current_element. + let mut cur = XmlCursor::new(vec![XmlEvent::StartElement("a".to_string())]); + assert!(cur.next_start_element()); + cur.skip_current_element(); + // Nested elements count depth; only depth-1 characters accumulate. + let mut cur = XmlCursor::new(vec![ + XmlEvent::StartElement("a".to_string()), + XmlEvent::Characters("x".to_string()), + XmlEvent::StartElement("b".to_string()), + XmlEvent::Characters("ignored".to_string()), + XmlEvent::EndElement("b".to_string()), + XmlEvent::Characters("y".to_string()), + XmlEvent::EndElement("a".to_string()), + ]); + assert!(cur.next_start_element()); + assert_eq!(cur.read_element_text(), "xy"); + } }