diff --git a/src/frame.rs b/src/frame.rs index eb511c3..849d92c 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -4863,1577 +4863,4 @@ pub enum BayerFrame16Error { #[cfg(all(test, feature = "std"))] #[cfg(any(feature = "std", feature = "alloc"))] -mod tests { - use super::*; - - fn planes() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { - // 16×8 frame, U/V are 8×4. - ( - std::vec![0u8; 16 * 8], - std::vec![128u8; 8 * 4], - std::vec![128u8; 8 * 4], - ) - } - - #[test] - fn try_new_accepts_valid_tight() { - let (y, u, v) = planes(); - let f = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - } - - #[test] - fn try_new_accepts_valid_padded_strides() { - // 16×8 frame, strides padded (32 for y, 16 for u/v). - let y = std::vec![0u8; 32 * 8]; - let u = std::vec![128u8; 16 * 4]; - let v = std::vec![128u8; 16 * 4]; - let f = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 32, 16, 16).expect("valid"); - assert_eq!(f.y_stride(), 32); - } - - #[test] - fn try_new_rejects_zero_dim() { - let (y, u, v) = planes(); - let e = Yuv420pFrame::try_new(&y, &u, &v, 0, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrameError::ZeroDimension { .. })); - } - - #[test] - fn try_new_rejects_odd_width() { - let (y, u, v) = planes(); - let e = Yuv420pFrame::try_new(&y, &u, &v, 15, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrameError::OddWidth { width: 15 })); - } - - #[test] - fn try_new_accepts_odd_height() { - // 16x9 frame — chroma_height = ceil(9/2) = 5. Y plane 16*9 = 144 - // bytes, U/V plane 8*5 = 40 bytes each. Valid 4:2:0 frame; - // height=9 must not be rejected just because it's odd. - let y = std::vec![0u8; 16 * 9]; - let u = std::vec![128u8; 8 * 5]; - let v = std::vec![128u8; 8 * 5]; - let f = Yuv420pFrame::try_new(&y, &u, &v, 16, 9, 16, 8, 8).expect("odd height valid"); - assert_eq!(f.height(), 9); - } - - #[test] - fn try_new_rejects_y_stride_under_width() { - let y = std::vec![0u8; 16 * 8]; - let u = std::vec![128u8; 8 * 4]; - let v = std::vec![128u8; 8 * 4]; - let e = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 8, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrameError::YStrideTooSmall { .. })); - } - - #[test] - fn try_new_rejects_short_y_plane() { - let y = std::vec![0u8; 10]; - let u = std::vec![128u8; 8 * 4]; - let v = std::vec![128u8; 8 * 4]; - let e = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrameError::YPlaneTooShort { .. })); - } - - #[test] - fn try_new_rejects_short_u_plane() { - let y = std::vec![0u8; 16 * 8]; - let u = std::vec![128u8; 4]; - let v = std::vec![128u8; 8 * 4]; - let e = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrameError::UPlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid Yuv420pFrame")] - fn new_panics_on_invalid() { - let y = std::vec![0u8; 10]; - let u = std::vec![128u8; 8 * 4]; - let v = std::vec![128u8; 8 * 4]; - let _ = Yuv420pFrame::new(&y, &u, &v, 16, 8, 16, 8, 8); - } - - // ---- Nv12Frame --------------------------------------------------------- - - fn nv12_planes() -> (std::vec::Vec, std::vec::Vec) { - // 16×8 frame → UV is 8 chroma columns × 4 chroma rows = 16 bytes/row. - (std::vec![0u8; 16 * 8], std::vec![128u8; 16 * 4]) - } - - #[test] - fn nv12_try_new_accepts_valid_tight() { - let (y, uv) = nv12_planes(); - let f = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - assert_eq!(f.uv_stride(), 16); - } - - #[test] - fn nv12_try_new_accepts_valid_padded_strides() { - let y = std::vec![0u8; 32 * 8]; - let uv = std::vec![128u8; 32 * 4]; - let f = Nv12Frame::try_new(&y, &uv, 16, 8, 32, 32).expect("valid"); - assert_eq!(f.y_stride(), 32); - assert_eq!(f.uv_stride(), 32); - } - - #[test] - fn nv12_try_new_rejects_zero_dim() { - let (y, uv) = nv12_planes(); - let e = Nv12Frame::try_new(&y, &uv, 0, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv12FrameError::ZeroDimension { .. })); - } - - #[test] - fn nv12_try_new_rejects_odd_width() { - let (y, uv) = nv12_planes(); - let e = Nv12Frame::try_new(&y, &uv, 15, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv12FrameError::OddWidth { width: 15 })); - } - - #[test] - fn nv12_try_new_accepts_odd_height() { - // 640x481 — concrete case flagged by adversarial review. chroma_height = - // ceil(481/2) = 241, so UV plane is 640*241 bytes. Constructor must - // accept this. - let y = std::vec![0u8; 640 * 481]; - let uv = std::vec![128u8; 640 * 241]; - let f = Nv12Frame::try_new(&y, &uv, 640, 481, 640, 640).expect("odd height valid"); - assert_eq!(f.height(), 481); - assert_eq!(f.width(), 640); - } - - #[test] - fn nv12_try_new_rejects_y_stride_under_width() { - let (y, uv) = nv12_planes(); - let e = Nv12Frame::try_new(&y, &uv, 16, 8, 8, 16).unwrap_err(); - assert!(matches!(e, Nv12FrameError::YStrideTooSmall { .. })); - } - - #[test] - fn nv12_try_new_rejects_uv_stride_under_width() { - let (y, uv) = nv12_planes(); - let e = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 8).unwrap_err(); - assert!(matches!(e, Nv12FrameError::UvStrideTooSmall { .. })); - } - - #[test] - fn nv12_try_new_rejects_short_y_plane() { - let y = std::vec![0u8; 10]; - let uv = std::vec![128u8; 16 * 4]; - let e = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv12FrameError::YPlaneTooShort { .. })); - } - - #[test] - fn nv12_try_new_rejects_short_uv_plane() { - let y = std::vec![0u8; 16 * 8]; - let uv = std::vec![128u8; 8]; - let e = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv12FrameError::UvPlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid Nv12Frame")] - fn nv12_new_panics_on_invalid() { - let y = std::vec![0u8; 10]; - let uv = std::vec![128u8; 16 * 4]; - let _ = Nv12Frame::new(&y, &uv, 16, 8, 16, 16); - } - - // ---- 32-bit overflow regressions -------------------------------------- - // - // `u32 * u32` can exceed `usize::MAX` only on 32-bit targets (wasm32, - // i686). Gate the tests so they actually run on those hosts under CI - // cross builds; on 64-bit they're trivially uninteresting (the - // product always fits). - - #[cfg(target_pointer_width = "32")] - #[test] - fn yuv420p_try_new_rejects_y_geometry_overflow() { - // 0x1_0000 * 0x1_0000 = 2^32, which overflows a 32-bit `usize` - // (max = 2^32 − 1). Even so the odd-width check passes, so we - // actually reach `checked_mul` and hit `GeometryOverflow`. - let big: u32 = 0x1_0000; - let y: [u8; 0] = []; - let u: [u8; 0] = []; - let v: [u8; 0] = []; - let e = Yuv420pFrame::try_new(&y, &u, &v, big, big, big, big / 2, big / 2).unwrap_err(); - assert!(matches!(e, Yuv420pFrameError::GeometryOverflow { .. })); - } - - #[cfg(target_pointer_width = "32")] - #[test] - fn nv12_try_new_rejects_geometry_overflow() { - let big: u32 = 0x1_0000; - let y: [u8; 0] = []; - let uv: [u8; 0] = []; - let e = Nv12Frame::try_new(&y, &uv, big, big, big, big).unwrap_err(); - assert!(matches!(e, Nv12FrameError::GeometryOverflow { .. })); - } - - // ---- Nv16Frame --------------------------------------------------------- - // - // 4:2:2: chroma is half-width, **full-height**. UV plane is `width * - // height` bytes (vs. NV12's `width * height / 2`). No height parity - // constraint. - - fn nv16_planes() -> (std::vec::Vec, std::vec::Vec) { - // 16×8 frame → UV is 8 chroma columns × 8 chroma rows = 16 bytes/row - // × 8 rows (not 4 — full height). - (std::vec![0u8; 16 * 8], std::vec![128u8; 16 * 8]) - } - - #[test] - fn nv16_try_new_accepts_valid_tight() { - let (y, uv) = nv16_planes(); - let f = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - assert_eq!(f.uv_stride(), 16); - } - - #[test] - fn nv16_try_new_accepts_valid_padded_strides() { - let y = std::vec![0u8; 32 * 8]; - let uv = std::vec![128u8; 32 * 8]; - let f = Nv16Frame::try_new(&y, &uv, 16, 8, 32, 32).expect("valid"); - assert_eq!(f.y_stride(), 32); - assert_eq!(f.uv_stride(), 32); - } - - #[test] - fn nv16_try_new_rejects_zero_dim() { - let (y, uv) = nv16_planes(); - let e = Nv16Frame::try_new(&y, &uv, 0, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv16FrameError::ZeroDimension { .. })); - } - - #[test] - fn nv16_try_new_rejects_odd_width() { - let (y, uv) = nv16_planes(); - let e = Nv16Frame::try_new(&y, &uv, 15, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv16FrameError::OddWidth { width: 15 })); - } - - #[test] - fn nv16_try_new_accepts_odd_height() { - // 4:2:2 has no height parity restriction (chroma is full-height, - // 1:1 per Y row). A 640x481 NV16 frame should construct fine. - let y = std::vec![0u8; 640 * 481]; - let uv = std::vec![128u8; 640 * 481]; - let f = Nv16Frame::try_new(&y, &uv, 640, 481, 640, 640).expect("odd height valid"); - assert_eq!(f.height(), 481); - assert_eq!(f.width(), 640); - } - - #[test] - fn nv16_try_new_rejects_y_stride_under_width() { - let (y, uv) = nv16_planes(); - let e = Nv16Frame::try_new(&y, &uv, 16, 8, 8, 16).unwrap_err(); - assert!(matches!(e, Nv16FrameError::YStrideTooSmall { .. })); - } - - #[test] - fn nv16_try_new_rejects_uv_stride_under_width() { - let (y, uv) = nv16_planes(); - let e = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 8).unwrap_err(); - assert!(matches!(e, Nv16FrameError::UvStrideTooSmall { .. })); - } - - #[test] - fn nv16_try_new_rejects_short_y_plane() { - let y = std::vec![0u8; 10]; - let uv = std::vec![128u8; 16 * 8]; - let e = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv16FrameError::YPlaneTooShort { .. })); - } - - #[test] - fn nv16_try_new_rejects_short_uv_plane() { - let y = std::vec![0u8; 16 * 8]; - // NV12 would accept `16 * 4 = 64` bytes here; NV16 needs full - // height → this must fail. - let uv = std::vec![128u8; 16 * 4]; - let e = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv16FrameError::UvPlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid Nv16Frame")] - fn nv16_new_panics_on_invalid() { - let y = std::vec![0u8; 10]; - let uv = std::vec![128u8; 16 * 8]; - let _ = Nv16Frame::new(&y, &uv, 16, 8, 16, 16); - } - - #[cfg(target_pointer_width = "32")] - #[test] - fn nv16_try_new_rejects_geometry_overflow() { - let big: u32 = 0x1_0000; - let y: [u8; 0] = []; - let uv: [u8; 0] = []; - let e = Nv16Frame::try_new(&y, &uv, big, big, big, big).unwrap_err(); - assert!(matches!(e, Nv16FrameError::GeometryOverflow { .. })); - } - - // ---- Nv24Frame --------------------------------------------------------- - // - // 4:4:4: chroma is full-width and full-height. UV plane is - // `2 * width * height` bytes. No width parity constraint. - - fn nv24_planes() -> (std::vec::Vec, std::vec::Vec) { - // 16×8 frame → UV is 16 chroma columns × 8 chroma rows = 32 bytes/row - // × 8 rows = 256 bytes. - (std::vec![0u8; 16 * 8], std::vec![128u8; 32 * 8]) - } - - #[test] - fn nv24_try_new_accepts_valid_tight() { - let (y, uv) = nv24_planes(); - let f = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 32).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - assert_eq!(f.uv_stride(), 32); - } - - #[test] - fn nv24_try_new_accepts_odd_width() { - // 4:4:4 has no width parity constraint. 17×8 → UV plane = 34 * 8. - let y = std::vec![0u8; 17 * 8]; - let uv = std::vec![128u8; 34 * 8]; - let f = Nv24Frame::try_new(&y, &uv, 17, 8, 17, 34).expect("odd width valid"); - assert_eq!(f.width(), 17); - } - - #[test] - fn nv24_try_new_accepts_odd_height() { - let y = std::vec![0u8; 16 * 7]; - let uv = std::vec![128u8; 32 * 7]; - let f = Nv24Frame::try_new(&y, &uv, 16, 7, 16, 32).expect("odd height valid"); - assert_eq!(f.height(), 7); - } - - #[test] - fn nv24_try_new_rejects_zero_dim() { - let (y, uv) = nv24_planes(); - let e = Nv24Frame::try_new(&y, &uv, 0, 8, 16, 32).unwrap_err(); - assert!(matches!(e, Nv24FrameError::ZeroDimension { .. })); - } - - #[test] - fn nv24_try_new_rejects_y_stride_under_width() { - let (y, uv) = nv24_planes(); - let e = Nv24Frame::try_new(&y, &uv, 16, 8, 8, 32).unwrap_err(); - assert!(matches!(e, Nv24FrameError::YStrideTooSmall { .. })); - } - - #[test] - fn nv24_try_new_rejects_uv_stride_under_double_width() { - let (y, uv) = nv24_planes(); - // 4:4:4 requires uv_stride >= 2 * width (= 32). 16 is insufficient. - let e = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv24FrameError::UvStrideTooSmall { .. })); - } - - #[test] - fn nv24_try_new_rejects_short_y_plane() { - let y = std::vec![0u8; 10]; - let uv = std::vec![128u8; 32 * 8]; - let e = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 32).unwrap_err(); - assert!(matches!(e, Nv24FrameError::YPlaneTooShort { .. })); - } - - #[test] - fn nv24_try_new_rejects_short_uv_plane() { - let y = std::vec![0u8; 16 * 8]; - let uv = std::vec![128u8; 32]; // one row instead of 8 - let e = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 32).unwrap_err(); - assert!(matches!(e, Nv24FrameError::UvPlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid Nv24Frame")] - fn nv24_new_panics_on_invalid() { - let y = std::vec![0u8; 10]; - let uv = std::vec![128u8; 32 * 8]; - let _ = Nv24Frame::new(&y, &uv, 16, 8, 16, 32); - } - - #[cfg(target_pointer_width = "32")] - #[test] - fn nv24_try_new_rejects_geometry_overflow() { - let big: u32 = 0x1_0000; - let y: [u8; 0] = []; - let uv: [u8; 0] = []; - // stride * height overflow path - let e = Nv24Frame::try_new(&y, &uv, big, big, big, big * 2).unwrap_err(); - assert!(matches!(e, Nv24FrameError::GeometryOverflow { .. })); - } - - #[test] - fn nv24_try_new_rejects_uv_width_overflow_u32() { - // `width * 2` overflows u32 → we report GeometryOverflow before - // even looking at uv_stride. - let y: [u8; 0] = []; - let uv: [u8; 0] = []; - // width >= 2^31 makes `width * 2` overflow u32. - let w: u32 = 0x8000_0000; - let e = Nv24Frame::try_new(&y, &uv, w, 1, w, 0).unwrap_err(); - assert!(matches!(e, Nv24FrameError::GeometryOverflow { .. })); - } - - // ---- Nv42Frame --------------------------------------------------------- - // - // Structurally identical to Nv24. Tests mirror the Nv24 set. - - fn nv42_planes() -> (std::vec::Vec, std::vec::Vec) { - (std::vec![0u8; 16 * 8], std::vec![128u8; 32 * 8]) - } - - #[test] - fn nv42_try_new_accepts_valid_tight() { - let (y, vu) = nv42_planes(); - let f = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 32).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.vu_stride(), 32); - } - - #[test] - fn nv42_try_new_accepts_odd_width() { - let y = std::vec![0u8; 17 * 8]; - let vu = std::vec![128u8; 34 * 8]; - let f = Nv42Frame::try_new(&y, &vu, 17, 8, 17, 34).expect("odd width valid"); - assert_eq!(f.width(), 17); - } - - #[test] - fn nv42_try_new_rejects_zero_dim() { - let (y, vu) = nv42_planes(); - let e = Nv42Frame::try_new(&y, &vu, 0, 8, 16, 32).unwrap_err(); - assert!(matches!(e, Nv42FrameError::ZeroDimension { .. })); - } - - #[test] - fn nv42_try_new_rejects_vu_stride_under_double_width() { - let (y, vu) = nv42_planes(); - let e = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv42FrameError::VuStrideTooSmall { .. })); - } - - #[test] - fn nv42_try_new_rejects_short_y_plane() { - let y = std::vec![0u8; 10]; - let vu = std::vec![128u8; 32 * 8]; - let e = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 32).unwrap_err(); - assert!(matches!(e, Nv42FrameError::YPlaneTooShort { .. })); - } - - #[test] - fn nv42_try_new_rejects_short_vu_plane() { - let y = std::vec![0u8; 16 * 8]; - let vu = std::vec![128u8; 32]; - let e = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 32).unwrap_err(); - assert!(matches!(e, Nv42FrameError::VuPlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid Nv42Frame")] - fn nv42_new_panics_on_invalid() { - let y = std::vec![0u8; 10]; - let vu = std::vec![128u8; 32 * 8]; - let _ = Nv42Frame::new(&y, &vu, 16, 8, 16, 32); - } - - // ---- Nv21Frame --------------------------------------------------------- - // - // NV21 is structurally identical to NV12 (same plane count, same - // stride/size math) — only the byte order within the chroma plane - // differs. Validation tests mirror the NV12 set. Kernel-level - // equivalence with NV12-swapped-UV is tested in `src/row/arch/*`. - - fn nv21_planes() -> (std::vec::Vec, std::vec::Vec) { - // 16×8 frame → VU is 16 bytes × 4 chroma rows. - (std::vec![0u8; 16 * 8], std::vec![128u8; 16 * 4]) - } - - #[test] - fn nv21_try_new_accepts_valid_tight() { - let (y, vu) = nv21_planes(); - let f = Nv21Frame::try_new(&y, &vu, 16, 8, 16, 16).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - assert_eq!(f.vu_stride(), 16); - } - - #[test] - fn nv21_try_new_accepts_odd_height() { - // Same concrete case as NV12 — 640x481. - let y = std::vec![0u8; 640 * 481]; - let vu = std::vec![128u8; 640 * 241]; - let f = Nv21Frame::try_new(&y, &vu, 640, 481, 640, 640).expect("odd height valid"); - assert_eq!(f.height(), 481); - } - - #[test] - fn nv21_try_new_rejects_odd_width() { - let (y, vu) = nv21_planes(); - let e = Nv21Frame::try_new(&y, &vu, 15, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv21FrameError::OddWidth { width: 15 })); - } - - #[test] - fn nv21_try_new_rejects_zero_dim() { - let (y, vu) = nv21_planes(); - let e = Nv21Frame::try_new(&y, &vu, 0, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv21FrameError::ZeroDimension { .. })); - } - - #[test] - fn nv21_try_new_rejects_vu_stride_under_width() { - let (y, vu) = nv21_planes(); - let e = Nv21Frame::try_new(&y, &vu, 16, 8, 16, 8).unwrap_err(); - assert!(matches!(e, Nv21FrameError::VuStrideTooSmall { .. })); - } - - #[test] - fn nv21_try_new_rejects_short_vu_plane() { - let y = std::vec![0u8; 16 * 8]; - let vu = std::vec![128u8; 8]; - let e = Nv21Frame::try_new(&y, &vu, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, Nv21FrameError::VuPlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid Nv21Frame")] - fn nv21_new_panics_on_invalid() { - let y = std::vec![0u8; 10]; - let vu = std::vec![128u8; 16 * 4]; - let _ = Nv21Frame::new(&y, &vu, 16, 8, 16, 16); - } - - #[cfg(target_pointer_width = "32")] - #[test] - fn nv21_try_new_rejects_geometry_overflow() { - let big: u32 = 0x1_0000; - let y: [u8; 0] = []; - let vu: [u8; 0] = []; - let e = Nv21Frame::try_new(&y, &vu, big, big, big, big).unwrap_err(); - assert!(matches!(e, Nv21FrameError::GeometryOverflow { .. })); - } - - // ---- Yuv420pFrame16 / Yuv420p10Frame ---------------------------------- - // - // Storage is `&[u16]` with sample-indexed strides. Validation mirrors - // the 8-bit [`Yuv420pFrame`] with the addition of the `BITS` guard. - - fn p10_planes() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { - // 16×8 frame, chroma 8×4. Y plane solid black (Y=0); UV planes - // neutral (UV=512 = 10‑bit chroma center). Exact sample values - // don't matter for the constructor tests that use this helper — - // they only look at shape, geometry errors, and the reported - // bits. - ( - std::vec![0u16; 16 * 8], - std::vec![512u16; 8 * 4], - std::vec![512u16; 8 * 4], - ) - } - - #[test] - fn yuv420p10_try_new_accepts_valid_tight() { - let (y, u, v) = p10_planes(); - let f = Yuv420p10Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - assert_eq!(f.bits(), 10); - } - - #[test] - fn yuv420p10_try_new_accepts_odd_height() { - // 16x9 → chroma_height = 5. Y plane 16*9 = 144 samples, U/V 8*5 = 40. - let y = std::vec![0u16; 16 * 9]; - let u = std::vec![512u16; 8 * 5]; - let v = std::vec![512u16; 8 * 5]; - let f = Yuv420p10Frame::try_new(&y, &u, &v, 16, 9, 16, 8, 8).expect("odd height valid"); - assert_eq!(f.height(), 9); - } - - #[test] - fn yuv420p10_try_new_rejects_odd_width() { - let (y, u, v) = p10_planes(); - let e = Yuv420p10Frame::try_new(&y, &u, &v, 15, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrame16Error::OddWidth { width: 15 })); - } - - #[test] - fn yuv420p10_try_new_rejects_zero_dim() { - let (y, u, v) = p10_planes(); - let e = Yuv420p10Frame::try_new(&y, &u, &v, 0, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrame16Error::ZeroDimension { .. })); - } - - #[test] - fn yuv420p10_try_new_rejects_short_y_plane() { - let y = std::vec![0u16; 10]; - let u = std::vec![512u16; 8 * 4]; - let v = std::vec![512u16; 8 * 4]; - let e = Yuv420p10Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrame16Error::YPlaneTooShort { .. })); - } - - #[test] - fn yuv420p10_try_new_rejects_short_u_plane() { - let y = std::vec![0u16; 16 * 8]; - let u = std::vec![512u16; 4]; - let v = std::vec![512u16; 8 * 4]; - let e = Yuv420p10Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrame16Error::UPlaneTooShort { .. })); - } - - #[test] - fn yuv420p_frame16_try_new_rejects_unsupported_bits() { - // BITS must be in {9, 10, 12, 14, 16}. 11, 15, etc. are rejected - // before any plane math runs. - let y = std::vec![0u16; 16 * 8]; - let u = std::vec![128u16; 8 * 4]; - let v = std::vec![128u16; 8 * 4]; - let e = Yuv420pFrame16::<11>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::UnsupportedBits { bits: 11 } - )); - let e15 = Yuv420pFrame16::<15>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!( - e15, - Yuv420pFrame16Error::UnsupportedBits { bits: 15 } - )); - } - - #[test] - fn yuv420p16_try_new_accepts_12_14_and_16() { - let y = std::vec![0u16; 16 * 8]; - let u = std::vec![2048u16; 8 * 4]; - let v = std::vec![2048u16; 8 * 4]; - let f12 = Yuv420pFrame16::<12>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("12-bit valid"); - assert_eq!(f12.bits(), 12); - let f14 = Yuv420pFrame16::<14>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("14-bit valid"); - assert_eq!(f14.bits(), 14); - let f16 = Yuv420p16Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("16-bit valid"); - assert_eq!(f16.bits(), 16); - } - - #[test] - fn yuv420p16_try_new_checked_accepts_full_u16_range() { - // At 16 bits the full u16 range is valid — max sample = 65535. - let y = std::vec![65535u16; 16 * 8]; - let u = std::vec![32768u16; 8 * 4]; - let v = std::vec![32768u16; 8 * 4]; - Yuv420p16Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8) - .expect("every u16 value is in range at 16 bits"); - } - - #[test] - fn p016_try_new_accepts_16bit() { - let y = std::vec![0xFFFFu16; 16 * 8]; - let uv = std::vec![0x8000u16; 16 * 4]; - let f = P016Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("P016 valid"); - assert_eq!(f.bits(), 16); - } - - #[test] - fn p016_try_new_checked_is_a_noop() { - // At BITS == 16 there are zero "low" bits to check — every u16 - // value is a valid P016 sample because `16 - BITS == 0`. The - // checked constructor therefore accepts everything. This pins - // that behavior in a test: at 16 bits the semantic distinction - // between P016 and yuv420p16le **cannot be detected** from - // sample values at all (no bit pattern is packing-specific). - let y = std::vec![0x1234u16; 16 * 8]; - let uv = std::vec![0x5678u16; 16 * 4]; - P016Frame::try_new_checked(&y, &uv, 16, 8, 16, 16) - .expect("every u16 passes the low-bits check at BITS == 16"); - } - - #[test] - fn pn_try_new_rejects_bits_other_than_10_12_16() { - let y = std::vec![0u16; 16 * 8]; - let uv = std::vec![0u16; 16 * 4]; - let e14 = PnFrame::<14>::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e14, PnFrameError::UnsupportedBits { bits: 14 })); - let e11 = PnFrame::<11>::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e11, PnFrameError::UnsupportedBits { bits: 11 })); - } - - #[test] - #[should_panic(expected = "invalid Yuv420pFrame16")] - fn yuv420p10_new_panics_on_invalid() { - let y = std::vec![0u16; 10]; - let u = std::vec![512u16; 8 * 4]; - let v = std::vec![512u16; 8 * 4]; - let _ = Yuv420p10Frame::new(&y, &u, &v, 16, 8, 16, 8, 8); - } - - #[cfg(target_pointer_width = "32")] - #[test] - fn yuv420p10_try_new_rejects_geometry_overflow() { - // Sample count overflow on 32-bit. Same rationale as the 8-bit - // version — strides are in `u16` elements here, so the same - // `0x1_0000 * 0x1_0000` product overflows `usize`. - let big: u32 = 0x1_0000; - let y: [u16; 0] = []; - let u: [u16; 0] = []; - let v: [u16; 0] = []; - let e = Yuv420p10Frame::try_new(&y, &u, &v, big, big, big, big / 2, big / 2).unwrap_err(); - assert!(matches!(e, Yuv420pFrame16Error::GeometryOverflow { .. })); - } - - #[test] - fn yuv420p10_try_new_checked_accepts_in_range_samples() { - // Same valid frame as `yuv420p10_try_new_accepts_valid_tight`, - // but run through the checked constructor. All samples live in - // the 10‑bit range. - let (y, u, v) = p10_planes(); - let f = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.bits(), 10); - } - - #[test] - fn yuv420p10_try_new_checked_rejects_y_high_bit_set() { - // A Y sample with bit 15 set — typical of `p010` packing where - // the 10 active bits sit in the high bits. `try_new` would - // accept this and let the SIMD kernels produce arch‑dependent - // garbage; `try_new_checked` catches it up front. - let mut y = std::vec![0u16; 16 * 8]; - y[3 * 16 + 5] = 0x8000; // bit 15 set → way above 1023 - let u = std::vec![512u16; 8 * 4]; - let v = std::vec![512u16; 8 * 4]; - let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - match e { - Yuv420pFrame16Error::SampleOutOfRange { - plane, - value, - max_valid, - .. - } => { - assert_eq!(plane, Yuv420pFrame16Plane::Y); - assert_eq!(value, 0x8000); - assert_eq!(max_valid, 1023); - } - other => panic!("expected SampleOutOfRange, got {other:?}"), - } - } - - #[test] - fn yuv420p10_try_new_checked_rejects_u_plane_sample() { - // Offending sample in the U plane — error must name U, not Y or V. - let y = std::vec![0u16; 16 * 8]; - let mut u = std::vec![512u16; 8 * 4]; - u[2 * 8 + 3] = 1024; // just above max - let v = std::vec![512u16; 8 * 4]; - let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::U, - value: 1024, - max_valid: 1023, - .. - } - )); - } - - #[test] - fn yuv420p10_try_new_checked_rejects_v_plane_sample() { - let y = std::vec![0u16; 16 * 8]; - let u = std::vec![512u16; 8 * 4]; - let mut v = std::vec![512u16; 8 * 4]; - v[8 + 7] = 0xFFFF; // all bits set - let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::V, - max_valid: 1023, - .. - } - )); - } - - #[test] - fn yuv420p10_try_new_checked_accepts_exact_max_sample() { - // Boundary: sample value == (1 << BITS) - 1 is valid. - let mut y = std::vec![0u16; 16 * 8]; - y[0] = 1023; - let u = std::vec![512u16; 8 * 4]; - let v = std::vec![512u16; 8 * 4]; - Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("1023 is in range"); - } - - #[test] - fn yuv420p10_try_new_checked_reports_geometry_errors_first() { - // If geometry is invalid, we never get to the sample scan — the - // same errors as `try_new` surface first. Prevents the checked - // path from doing unnecessary O(N) work on inputs that would - // fail for a simpler reason. - let y = std::vec![0u16; 10]; // Too small. - let u = std::vec![512u16; 8 * 4]; - let v = std::vec![512u16; 8 * 4]; - let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!(e, Yuv420pFrame16Error::YPlaneTooShort { .. })); - } - - // ---- P010Frame --------------------------------------------------------- - // - // Semi‑planar 10‑bit. Plane shape mirrors Nv12Frame (Y + interleaved - // UV) but sample width is `u16` with the 10 active bits in the - // **high** 10 of each element (`value << 6`). Strides are in - // samples, not bytes. - - fn p010_planes() -> (std::vec::Vec, std::vec::Vec) { - // 16×8 frame — UV plane carries 16 u16 × 4 chroma rows = 64 u16. - // P010 white Y = 1023 << 6 = 0xFFC0; neutral UV = 512 << 6 = 0x8000. - (std::vec![0xFFC0u16; 16 * 8], std::vec![0x8000u16; 16 * 4]) - } - - #[test] - fn p010_try_new_accepts_valid_tight() { - let (y, uv) = p010_planes(); - let f = P010Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - assert_eq!(f.uv_stride(), 16); - } - - #[test] - fn p010_try_new_accepts_odd_height() { - // 640×481 — same concrete odd‑height case covered by NV12 / NV21. - let y = std::vec![0u16; 640 * 481]; - let uv = std::vec![0x8000u16; 640 * 241]; - let f = P010Frame::try_new(&y, &uv, 640, 481, 640, 640).expect("odd height valid"); - assert_eq!(f.height(), 481); - } - - #[test] - fn p010_try_new_rejects_odd_width() { - let (y, uv) = p010_planes(); - let e = P010Frame::try_new(&y, &uv, 15, 8, 16, 16).unwrap_err(); - assert!(matches!(e, PnFrameError::OddWidth { width: 15 })); - } - - #[test] - fn p010_try_new_rejects_zero_dim() { - let (y, uv) = p010_planes(); - let e = P010Frame::try_new(&y, &uv, 0, 8, 16, 16).unwrap_err(); - assert!(matches!(e, PnFrameError::ZeroDimension { .. })); - } - - #[test] - fn p010_try_new_rejects_y_stride_under_width() { - let (y, uv) = p010_planes(); - let e = P010Frame::try_new(&y, &uv, 16, 8, 8, 16).unwrap_err(); - assert!(matches!(e, PnFrameError::YStrideTooSmall { .. })); - } - - #[test] - fn p010_try_new_rejects_uv_stride_under_width() { - let (y, uv) = p010_planes(); - let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 8).unwrap_err(); - assert!(matches!(e, PnFrameError::UvStrideTooSmall { .. })); - } - - #[test] - fn p010_try_new_rejects_odd_uv_stride() { - // uv_stride = 17 passes the size check (>= width = 16) but is - // odd, which would mis-align the (U, V) pair on every other row. - let y = std::vec![0u16; 16 * 8]; - let uv = std::vec![0x8000u16; 17 * 4]; - let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 17).unwrap_err(); - assert!(matches!(e, PnFrameError::UvStrideOdd { uv_stride: 17 })); - } - - #[test] - fn p210_try_new_rejects_odd_uv_stride() { - // PnFrame422 chroma is half-width × full-height with 2 u16 per - // pair → uv_row_elems = width. Same odd-stride bug as P010. - let y = std::vec![0u16; 16 * 8]; - let uv = std::vec![0x8000u16; 17 * 8]; - let e = P210Frame::try_new(&y, &uv, 16, 8, 16, 17).unwrap_err(); - assert!(matches!(e, PnFrameError::UvStrideOdd { uv_stride: 17 })); - } - - #[test] - fn p410_try_new_rejects_odd_uv_stride() { - // PnFrame444 chroma is full-width × full-height with 2 u16 per - // pair → uv_row_elems = 2 * width = 32. uv_stride = 33 passes - // the size check but is odd. - let y = std::vec![0u16; 16 * 8]; - let uv = std::vec![0x8000u16; 33 * 8]; - let e = P410Frame::try_new(&y, &uv, 16, 8, 16, 33).unwrap_err(); - assert!(matches!(e, PnFrameError::UvStrideOdd { uv_stride: 33 })); - } - - #[test] - fn p010_try_new_rejects_short_y_plane() { - let y = std::vec![0u16; 10]; - let uv = std::vec![0x8000u16; 16 * 4]; - let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, PnFrameError::YPlaneTooShort { .. })); - } - - #[test] - fn p010_try_new_rejects_short_uv_plane() { - let y = std::vec![0u16; 16 * 8]; - let uv = std::vec![0x8000u16; 8]; - let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, PnFrameError::UvPlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid PnFrame")] - fn p010_new_panics_on_invalid() { - let y = std::vec![0u16; 10]; - let uv = std::vec![0x8000u16; 16 * 4]; - let _ = P010Frame::new(&y, &uv, 16, 8, 16, 16); - } - - #[cfg(target_pointer_width = "32")] - #[test] - fn p010_try_new_rejects_geometry_overflow() { - let big: u32 = 0x1_0000; - let y: [u16; 0] = []; - let uv: [u16; 0] = []; - let e = P010Frame::try_new(&y, &uv, big, big, big, big).unwrap_err(); - assert!(matches!(e, PnFrameError::GeometryOverflow { .. })); - } - - #[test] - fn p010_try_new_checked_accepts_shifted_samples() { - // Valid P010 samples: low 6 bits zero. - let (y, uv) = p010_planes(); - P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).expect("shifted samples valid"); - } - - #[test] - fn p010_try_new_checked_rejects_y_low_bits_set() { - // A Y sample with low 6 bits set — characteristic of yuv420p10le - // packing (value in low 10 bits) accidentally handed to the P010 - // constructor. `try_new_checked` catches this; plain `try_new` - // would let the kernel mask it down and produce wrong colors. - let mut y = std::vec![0xFFC0u16; 16 * 8]; - y[3 * 16 + 5] = 0x03FF; // 10-bit value in low bits — wrong packing - let uv = std::vec![0x8000u16; 16 * 4]; - let e = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); - match e { - PnFrameError::SampleLowBitsSet { plane, value, .. } => { - assert_eq!(plane, P010FramePlane::Y); - assert_eq!(value, 0x03FF); - } - other => panic!("expected SampleLowBitsSet, got {other:?}"), - } - } - - #[test] - fn p010_try_new_checked_rejects_uv_plane_sample() { - let y = std::vec![0xFFC0u16; 16 * 8]; - let mut uv = std::vec![0x8000u16; 16 * 4]; - uv[2 * 16 + 3] = 0x0001; // low bit set - let e = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!( - e, - PnFrameError::SampleLowBitsSet { - plane: P010FramePlane::Uv, - value: 0x0001, - .. - } - )); - } - - #[test] - fn p010_try_new_checked_reports_geometry_errors_first() { - let y = std::vec![0u16; 10]; // Too small. - let uv = std::vec![0x8000u16; 16 * 4]; - let e = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); - assert!(matches!(e, PnFrameError::YPlaneTooShort { .. })); - } - - /// Regression documenting a **known limitation** of - /// [`P010Frame::try_new_checked`]: the low‑6‑bits‑zero check is a - /// packing sanity check, not a provenance validator. A - /// `yuv420p10le` buffer whose samples all happen to be multiples - /// of 64 — e.g. `Y = 64` (limited‑range black, `0x0040`) and - /// `UV = 512` (neutral chroma, `0x0200`) — passes the check - /// silently, even though the layout is wrong and downstream P010 - /// kernels will produce incorrect output. - /// - /// The test asserts the check accepts these values so the limit - /// is visible in the test log; any future attempt to tighten the - /// constructor into a real provenance validator will need to - /// update or replace this test. - #[test] - fn p010_try_new_checked_accepts_ambiguous_yuv420p10le_samples() { - // `yuv420p10le`-style samples, all multiples of 64: low 6 bits - // are zero, so they pass the P010 sanity check even though this - // is wrong data for a P010 frame. - let y = std::vec![0x0040u16; 16 * 8]; // limited-range black in 10-bit low-packed - let uv = std::vec![0x0200u16; 16 * 4]; // neutral chroma in 10-bit low-packed - let f = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16) - .expect("known limitation: low-6-bits-zero check cannot tell yuv420p10le from P010"); - assert_eq!(f.width(), 16); - // Downstream decoding of this frame would produce wrong colors - // (every `>> 6` extracts 1 from Y=0x0040 and 8 from UV=0x0200, - // which P010 kernels then bias/scale as if those were the 10-bit - // source values). That's accepted behavior — the type system, - // not `try_new_checked`, is what keeps yuv420p10le out of P010. - } - - #[test] - fn p012_try_new_checked_accepts_shifted_samples() { - // Valid P012 samples: low 4 bits zero (12-bit value << 4). - let y = std::vec![(2048u16) << 4; 16 * 8]; // 12-bit mid-gray shifted up - let uv = std::vec![(2048u16) << 4; 16 * 4]; - P012Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).expect("shifted samples valid"); - } - - #[test] - fn p012_try_new_checked_rejects_low_bits_set() { - // A Y sample with any of the low 4 bits set — e.g. yuv420p12le - // value 0x0ABC landing where P012 expects `value << 4`. The check - // catches samples like this that are obviously mispacked. - let mut y = std::vec![(2048u16) << 4; 16 * 8]; - y[3 * 16 + 5] = 0x0ABC; // low 4 bits = 0xC ≠ 0 - let uv = std::vec![(2048u16) << 4; 16 * 4]; - let e = P012Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); - match e { - PnFrameError::SampleLowBitsSet { - plane, - value, - low_bits, - .. - } => { - assert_eq!(plane, PnFramePlane::Y); - assert_eq!(value, 0x0ABC); - assert_eq!(low_bits, 4); - } - other => panic!("expected SampleLowBitsSet, got {other:?}"), - } - } - - /// Regression documenting a **worse known limitation** of - /// [`P012Frame::try_new_checked`] compared to P010: because the - /// low‑bits check only has 4 bits to work with at `BITS == 12`, - /// every multiple‑of‑16 `yuv420p12le` value passes silently. The - /// practical impact is that common limited‑range flat‑region - /// content in real decoder output — `Y = 256` (limited‑range - /// black), `UV = 2048` (neutral chroma), `Y = 1024` (full black) - /// — is entirely invisible to this check. - /// - /// This test pins the limitation with a reproducible input so - /// that: - /// 1. Users reading the test suite can see the exact failure - /// mode for `try_new_checked` on 12‑bit data. - /// 2. Any future attempt to strengthen `try_new_checked` (e.g., - /// into a statistical provenance heuristic) has a concrete - /// input to validate against. - /// 3. The `PnFrame` docs' warning about this limitation has a - /// named test to point to. - /// - /// For P012, the type system (choosing [`P012Frame`] vs - /// [`Yuv420p12Frame`] at construction based on decoder metadata) - /// is the only reliable provenance guarantee. - #[test] - fn p012_try_new_checked_accepts_low_packed_flat_content_by_design() { - // All values are multiples of 16 — exactly the set that slips - // through a 4-low-bits-zero check. `yuv420p12le` limited-range - // black and neutral chroma both satisfy this. - let y = std::vec![0x0100u16; 16 * 8]; // Y = 256 (limited-range black), multiple of 16 - let uv = std::vec![0x0800u16; 16 * 4]; // UV = 2048 (neutral chroma), multiple of 16 - let f = P012Frame::try_new_checked(&y, &uv, 16, 8, 16, 16) - .expect("known limitation: 4-low-bits-zero check cannot tell yuv420p12le from P012"); - assert_eq!(f.width(), 16); - // Downstream P012 kernels would extract `>> 4` — giving Y=16 and - // UV=128 instead of the intended Y=256 and UV=2048. Silent color - // corruption. The type system, not `try_new_checked`, must - // guarantee provenance for 12-bit. - } - - // ---- Yuv422pFrame16::try_new_checked --------------------------------- - - fn p422_planes_10bit() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { - // Width 16, height 8 — 4:2:2 chroma is half-width, FULL-height. - let y = std::vec![64u16; 16 * 8]; - let u = std::vec![512u16; 8 * 8]; - let v = std::vec![512u16; 8 * 8]; - (y, u, v) - } - - #[test] - fn yuv422p10_try_new_checked_accepts_in_range_samples() { - let (y, u, v) = p422_planes_10bit(); - let f = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid 10-bit"); - assert_eq!(f.width(), 16); - assert_eq!(f.bits(), 10); - } - - #[test] - fn yuv422p10_try_new_checked_accepts_max_valid_value() { - // Exactly `(1 << 10) - 1 = 1023` must pass. - let y = std::vec![1023u16; 16 * 8]; - let u = std::vec![1023u16; 8 * 8]; - let v = std::vec![1023u16; 8 * 8]; - Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("max valid passes"); - } - - #[test] - fn yuv422p10_try_new_checked_rejects_y_high_bit_set() { - let mut y = std::vec![0u16; 16 * 8]; - y[3 * 16 + 5] = 0x8000; - let u = std::vec![512u16; 8 * 8]; - let v = std::vec![512u16; 8 * 8]; - let e = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - match e { - Yuv420pFrame16Error::SampleOutOfRange { - plane, - value, - max_valid, - .. - } => { - assert_eq!(plane, Yuv420pFrame16Plane::Y); - assert_eq!(value, 0x8000); - assert_eq!(max_valid, 1023); - } - other => panic!("expected SampleOutOfRange, got {other:?}"), - } - } - - #[test] - fn yuv422p10_try_new_checked_rejects_u_plane_sample_in_full_height_chroma() { - // Crucial 4:2:2-specific test: the offending sample is on the - // last chroma row (row 7), which only exists because 4:2:2 - // chroma is full-height (8 rows). The 4:2:0 scan would stop at - // row 3. - let y = std::vec![0u16; 16 * 8]; - let mut u = std::vec![512u16; 8 * 8]; - u[7 * 8 + 3] = 1024; // last chroma row, just above max - let v = std::vec![512u16; 8 * 8]; - let e = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::U, - value: 1024, - max_valid: 1023, - .. - } - )); - } - - #[test] - fn yuv422p10_try_new_checked_rejects_v_plane_sample() { - let y = std::vec![0u16; 16 * 8]; - let u = std::vec![512u16; 8 * 8]; - let mut v = std::vec![512u16; 8 * 8]; - v[5 * 8 + 6] = 0xFFFF; - let e = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::V, - .. - } - )); - } - - #[test] - fn yuv422p12_try_new_checked_rejects_above_4095() { - let mut y = std::vec![2048u16; 16 * 8]; - y[0] = 4096; // just above 12-bit max - let u = std::vec![2048u16; 8 * 8]; - let v = std::vec![2048u16; 8 * 8]; - let e = Yuv422p12Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - value: 4096, - max_valid: 4095, - .. - } - )); - } - - #[test] - fn yuv422p16_try_new_checked_accepts_full_u16_range() { - // At 16 bits the full u16 range is valid — no scan needed. - let y = std::vec![65535u16; 16 * 8]; - let u = std::vec![32768u16; 8 * 8]; - let v = std::vec![32768u16; 8 * 8]; - Yuv422p16Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8) - .expect("every u16 value is in range at 16 bits"); - } - - // ---- Yuv444pFrame16::try_new_checked --------------------------------- - - fn p444_planes_10bit() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { - // 4:4:4: chroma is FULL-width, full-height (1:1 with Y). - let y = std::vec![64u16; 16 * 8]; - let u = std::vec![512u16; 16 * 8]; - let v = std::vec![512u16; 16 * 8]; - (y, u, v) - } - - #[test] - fn yuv444p10_try_new_checked_accepts_in_range_samples() { - let (y, u, v) = p444_planes_10bit(); - let f = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).expect("valid 10-bit"); - assert_eq!(f.width(), 16); - assert_eq!(f.bits(), 10); - } - - #[test] - fn yuv444p10_try_new_checked_accepts_max_valid_value() { - let y = std::vec![1023u16; 16 * 8]; - let u = std::vec![1023u16; 16 * 8]; - let v = std::vec![1023u16; 16 * 8]; - Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).expect("max valid passes"); - } - - #[test] - fn yuv444p10_try_new_checked_rejects_y_high_bit_set() { - let mut y = std::vec![0u16; 16 * 8]; - y[2 * 16 + 9] = 0x8000; - let u = std::vec![512u16; 16 * 8]; - let v = std::vec![512u16; 16 * 8]; - let e = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::Y, - value: 0x8000, - max_valid: 1023, - .. - } - )); - } - - #[test] - fn yuv444p10_try_new_checked_rejects_u_plane_sample_in_full_width_chroma() { - // 4:4:4-specific: the offending sample is in the FULL-WIDTH - // chroma plane, at column 13 (which doesn't exist in 4:2:0/4:2:2 - // half-width chroma). Forces the scan to extend across the full - // chroma width. - let y = std::vec![0u16; 16 * 8]; - let mut u = std::vec![512u16; 16 * 8]; - u[3 * 16 + 13] = 1024; - let v = std::vec![512u16; 16 * 8]; - let e = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::U, - value: 1024, - max_valid: 1023, - .. - } - )); - } - - #[test] - fn yuv444p10_try_new_checked_rejects_v_plane_sample() { - let y = std::vec![0u16; 16 * 8]; - let u = std::vec![512u16; 16 * 8]; - let mut v = std::vec![512u16; 16 * 8]; - v[7 * 16 + 15] = 0xFFFF; // last chroma sample - let e = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::V, - .. - } - )); - } - - #[test] - fn yuv444p14_try_new_checked_rejects_above_16383() { - let mut y = std::vec![8192u16; 16 * 8]; - y[42] = 16384; // just above 14-bit max - let u = std::vec![8192u16; 16 * 8]; - let v = std::vec![8192u16; 16 * 8]; - let e = Yuv444p14Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - value: 16384, - max_valid: 16383, - .. - } - )); - } - - #[test] - fn yuv444p16_try_new_checked_accepts_full_u16_range() { - let y = std::vec![65535u16; 16 * 8]; - let u = std::vec![32768u16; 16 * 8]; - let v = std::vec![32768u16; 16 * 8]; - Yuv444p16Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16) - .expect("every u16 value is in range at 16 bits"); - } - - // ---- Yuv440p10/12 checked-constructor tests --------------------------- - - fn p440_planes_10bit() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { - // 4:4:0: chroma is FULL-width × HALF-height (8 / 2 = 4 chroma rows). - let y = std::vec![64u16; 16 * 8]; - let u = std::vec![512u16; 16 * 4]; - let v = std::vec![512u16; 16 * 4]; - (y, u, v) - } - - #[test] - fn yuv440p10_try_new_checked_accepts_in_range_samples() { - let (y, u, v) = p440_planes_10bit(); - let f = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).expect("valid 10-bit"); - assert_eq!(f.width(), 16); - assert_eq!(f.bits(), 10); - } - - #[test] - fn yuv440p10_try_new_checked_rejects_y_high_bit_set() { - let mut y = std::vec![0u16; 16 * 8]; - y[2 * 16 + 9] = 0x8000; - let u = std::vec![512u16; 16 * 4]; - let v = std::vec![512u16; 16 * 4]; - let e = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::Y, - value: 0x8000, - max_valid: 1023, - .. - } - )); - } - - #[test] - fn yuv440p10_try_new_checked_rejects_u_plane_sample_in_full_width_chroma() { - // 4:4:0-specific: chroma is full-width × half-height. Plant the - // bad sample at column 13 (would be out of range for half-width - // 4:2:0/4:2:2 chroma) on the last chroma row (index 3 for height - // 8 ⇒ 4 chroma rows). - let y = std::vec![0u16; 16 * 8]; - let mut u = std::vec![512u16; 16 * 4]; - u[3 * 16 + 13] = 1024; - let v = std::vec![512u16; 16 * 4]; - let e = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::U, - value: 1024, - max_valid: 1023, - .. - } - )); - } - - #[test] - fn yuv440p10_try_new_checked_rejects_v_plane_sample() { - let y = std::vec![0u16; 16 * 8]; - let u = std::vec![512u16; 16 * 4]; - let mut v = std::vec![512u16; 16 * 4]; - v[3 * 16 + 15] = 0xFFFF; // last chroma sample of the last chroma row - let e = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - plane: Yuv420pFrame16Plane::V, - .. - } - )); - } - - #[test] - fn yuv440p12_try_new_checked_rejects_above_4095() { - let mut y = std::vec![2048u16; 16 * 8]; - y[42] = 4096; // just above 12-bit max - let u = std::vec![2048u16; 16 * 4]; - let v = std::vec![2048u16; 16 * 4]; - let e = Yuv440p12Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); - assert!(matches!( - e, - Yuv420pFrame16Error::SampleOutOfRange { - value: 4096, - max_valid: 4095, - .. - } - )); - } - - // ----- BayerFrame (8-bit) ----- - - #[test] - fn bayer_try_new_accepts_valid_tight() { - let data = std::vec![0u8; 16 * 8]; - let f = BayerFrame::try_new(&data, 16, 8, 16).expect("valid"); - assert_eq!(f.width(), 16); - assert_eq!(f.height(), 8); - assert_eq!(f.stride(), 16); - } - - #[test] - fn bayer_try_new_accepts_padded_stride() { - let data = std::vec![0u8; 24 * 8]; - let f = BayerFrame::try_new(&data, 16, 8, 24).expect("padded stride valid"); - assert_eq!(f.stride(), 24); - } - - #[test] - fn bayer_try_new_rejects_zero_dim() { - let data = std::vec![0u8; 16 * 8]; - let e = BayerFrame::try_new(&data, 0, 8, 16).unwrap_err(); - assert!(matches!(e, BayerFrameError::ZeroDimension { .. })); - let e = BayerFrame::try_new(&data, 16, 0, 16).unwrap_err(); - assert!(matches!(e, BayerFrameError::ZeroDimension { .. })); - } - - #[test] - fn bayer_try_new_accepts_odd_width() { - // Cropped Bayer planes can have odd dimensions; the kernel - // handles partial 2×2 tiles via edge clamping. - let data = std::vec![0u8; 15 * 8]; - let f = BayerFrame::try_new(&data, 15, 8, 15).expect("odd width valid"); - assert_eq!(f.width(), 15); - } - - #[test] - fn bayer_try_new_accepts_odd_height() { - let data = std::vec![0u8; 16 * 7]; - let f = BayerFrame::try_new(&data, 16, 7, 16).expect("odd height valid"); - assert_eq!(f.height(), 7); - } - - #[test] - fn bayer_try_new_accepts_odd_width_and_height() { - let data = std::vec![0u8; 15 * 7]; - let f = BayerFrame::try_new(&data, 15, 7, 15).expect("odd both valid"); - assert_eq!(f.width(), 15); - assert_eq!(f.height(), 7); - } - - #[test] - fn bayer_try_new_accepts_1x1() { - let data = std::vec![42u8]; - let f = BayerFrame::try_new(&data, 1, 1, 1).expect("1x1 valid"); - assert_eq!(f.width(), 1); - assert_eq!(f.height(), 1); - } - - #[test] - fn bayer_try_new_rejects_stride_under_width() { - let data = std::vec![0u8; 16 * 8]; - let e = BayerFrame::try_new(&data, 16, 8, 8).unwrap_err(); - assert!(matches!(e, BayerFrameError::StrideTooSmall { .. })); - } - - #[test] - fn bayer_try_new_rejects_short_plane() { - let data = std::vec![0u8; 10]; - let e = BayerFrame::try_new(&data, 16, 8, 16).unwrap_err(); - assert!(matches!(e, BayerFrameError::PlaneTooShort { .. })); - } - - #[test] - #[should_panic(expected = "invalid BayerFrame")] - fn bayer_new_panics_on_invalid() { - let data = std::vec![0u8; 10]; - let _ = BayerFrame::new(&data, 16, 8, 16); - } - - // ----- BayerFrame16 (high-bit-depth) ----- - - #[test] - fn bayer16_try_new_rejects_unsupported_bits() { - let data = std::vec![0u16; 16 * 8]; - let e = BayerFrame16::<11>::try_new(&data, 16, 8, 16).unwrap_err(); - assert!(matches!(e, BayerFrame16Error::UnsupportedBits { bits: 11 })); - let e = BayerFrame16::<8>::try_new(&data, 16, 8, 16).unwrap_err(); - assert!(matches!(e, BayerFrame16Error::UnsupportedBits { bits: 8 })); - } - - #[test] - fn bayer16_try_new_accepts_each_supported_bits() { - let data = std::vec![0u16; 16 * 8]; - Bayer10Frame::try_new(&data, 16, 8, 16).expect("10"); - Bayer12Frame::try_new(&data, 16, 8, 16).expect("12"); - Bayer14Frame::try_new(&data, 16, 8, 16).expect("14"); - Bayer16Frame::try_new(&data, 16, 8, 16).expect("16"); - } - - #[test] - fn bayer16_try_new_accepts_odd_dims() { - let data = std::vec![0u16; 15 * 7]; - let f = Bayer12Frame::try_new(&data, 15, 7, 15).expect("odd both valid"); - assert_eq!(f.width(), 15); - assert_eq!(f.height(), 7); - } - - #[test] - fn bayer16_try_new_accepts_low_packed_12bit() { - // 12-bit low-packed: every value ≤ 4095 is valid. - let mut data = std::vec![2048u16; 16 * 8]; - data[7] = 4095; // max valid 12-bit - data[42] = 0; // black - Bayer12Frame::try_new(&data, 16, 8, 16).expect("12-bit low-packed"); - } - - #[test] - fn bayer16_try_new_rejects_above_max_at_12bit() { - let mut data = std::vec![2048u16; 16 * 8]; - data[42] = 4096; // just above 12-bit max - let e = Bayer12Frame::try_new(&data, 16, 8, 16).unwrap_err(); - assert!(matches!( - e, - BayerFrame16Error::SampleOutOfRange { - index: 42, - value: 4096, - max_valid: 4095, - } - )); - } - - #[test] - fn bayer16_try_new_rejects_above_max_at_10bit() { - let mut data = std::vec![512u16; 16 * 8]; - data[3] = 1024; // just above 10-bit max - let e = Bayer10Frame::try_new(&data, 16, 8, 16).unwrap_err(); - assert!(matches!( - e, - BayerFrame16Error::SampleOutOfRange { - index: 3, - value: 1024, - max_valid: 1023, - } - )); - } - - #[test] - fn bayer16_try_new_accepts_full_u16_range_at_16bit() { - // At BITS=16 every u16 is valid. - let mut data = std::vec![0u16; 16 * 8]; - data[7] = 0xFFFF; - data[42] = 0x1234; - Bayer16Frame::try_new(&data, 16, 8, 16).expect("any u16 valid at 16-bit"); - } - - #[test] - #[should_panic(expected = "invalid BayerFrame16")] - fn bayer16_new_panics_on_invalid() { - let data = std::vec![0u16; 10]; - let _ = Bayer12Frame::new(&data, 16, 8, 16); - } -} +mod tests; diff --git a/src/frame/tests.rs b/src/frame/tests.rs new file mode 100644 index 0000000..c3bd14e --- /dev/null +++ b/src/frame/tests.rs @@ -0,0 +1,1572 @@ +use super::*; + +fn planes() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { + // 16×8 frame, U/V are 8×4. + ( + std::vec![0u8; 16 * 8], + std::vec![128u8; 8 * 4], + std::vec![128u8; 8 * 4], + ) +} + +#[test] +fn try_new_accepts_valid_tight() { + let (y, u, v) = planes(); + let f = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); +} + +#[test] +fn try_new_accepts_valid_padded_strides() { + // 16×8 frame, strides padded (32 for y, 16 for u/v). + let y = std::vec![0u8; 32 * 8]; + let u = std::vec![128u8; 16 * 4]; + let v = std::vec![128u8; 16 * 4]; + let f = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 32, 16, 16).expect("valid"); + assert_eq!(f.y_stride(), 32); +} + +#[test] +fn try_new_rejects_zero_dim() { + let (y, u, v) = planes(); + let e = Yuv420pFrame::try_new(&y, &u, &v, 0, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrameError::ZeroDimension { .. })); +} + +#[test] +fn try_new_rejects_odd_width() { + let (y, u, v) = planes(); + let e = Yuv420pFrame::try_new(&y, &u, &v, 15, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrameError::OddWidth { width: 15 })); +} + +#[test] +fn try_new_accepts_odd_height() { + // 16x9 frame — chroma_height = ceil(9/2) = 5. Y plane 16*9 = 144 + // bytes, U/V plane 8*5 = 40 bytes each. Valid 4:2:0 frame; + // height=9 must not be rejected just because it's odd. + let y = std::vec![0u8; 16 * 9]; + let u = std::vec![128u8; 8 * 5]; + let v = std::vec![128u8; 8 * 5]; + let f = Yuv420pFrame::try_new(&y, &u, &v, 16, 9, 16, 8, 8).expect("odd height valid"); + assert_eq!(f.height(), 9); +} + +#[test] +fn try_new_rejects_y_stride_under_width() { + let y = std::vec![0u8; 16 * 8]; + let u = std::vec![128u8; 8 * 4]; + let v = std::vec![128u8; 8 * 4]; + let e = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 8, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrameError::YStrideTooSmall { .. })); +} + +#[test] +fn try_new_rejects_short_y_plane() { + let y = std::vec![0u8; 10]; + let u = std::vec![128u8; 8 * 4]; + let v = std::vec![128u8; 8 * 4]; + let e = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrameError::YPlaneTooShort { .. })); +} + +#[test] +fn try_new_rejects_short_u_plane() { + let y = std::vec![0u8; 16 * 8]; + let u = std::vec![128u8; 4]; + let v = std::vec![128u8; 8 * 4]; + let e = Yuv420pFrame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrameError::UPlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid Yuv420pFrame")] +fn new_panics_on_invalid() { + let y = std::vec![0u8; 10]; + let u = std::vec![128u8; 8 * 4]; + let v = std::vec![128u8; 8 * 4]; + let _ = Yuv420pFrame::new(&y, &u, &v, 16, 8, 16, 8, 8); +} + +// ---- Nv12Frame --------------------------------------------------------- + +fn nv12_planes() -> (std::vec::Vec, std::vec::Vec) { + // 16×8 frame → UV is 8 chroma columns × 4 chroma rows = 16 bytes/row. + (std::vec![0u8; 16 * 8], std::vec![128u8; 16 * 4]) +} + +#[test] +fn nv12_try_new_accepts_valid_tight() { + let (y, uv) = nv12_planes(); + let f = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); + assert_eq!(f.uv_stride(), 16); +} + +#[test] +fn nv12_try_new_accepts_valid_padded_strides() { + let y = std::vec![0u8; 32 * 8]; + let uv = std::vec![128u8; 32 * 4]; + let f = Nv12Frame::try_new(&y, &uv, 16, 8, 32, 32).expect("valid"); + assert_eq!(f.y_stride(), 32); + assert_eq!(f.uv_stride(), 32); +} + +#[test] +fn nv12_try_new_rejects_zero_dim() { + let (y, uv) = nv12_planes(); + let e = Nv12Frame::try_new(&y, &uv, 0, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv12FrameError::ZeroDimension { .. })); +} + +#[test] +fn nv12_try_new_rejects_odd_width() { + let (y, uv) = nv12_planes(); + let e = Nv12Frame::try_new(&y, &uv, 15, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv12FrameError::OddWidth { width: 15 })); +} + +#[test] +fn nv12_try_new_accepts_odd_height() { + // 640x481 — concrete case flagged by adversarial review. chroma_height = + // ceil(481/2) = 241, so UV plane is 640*241 bytes. Constructor must + // accept this. + let y = std::vec![0u8; 640 * 481]; + let uv = std::vec![128u8; 640 * 241]; + let f = Nv12Frame::try_new(&y, &uv, 640, 481, 640, 640).expect("odd height valid"); + assert_eq!(f.height(), 481); + assert_eq!(f.width(), 640); +} + +#[test] +fn nv12_try_new_rejects_y_stride_under_width() { + let (y, uv) = nv12_planes(); + let e = Nv12Frame::try_new(&y, &uv, 16, 8, 8, 16).unwrap_err(); + assert!(matches!(e, Nv12FrameError::YStrideTooSmall { .. })); +} + +#[test] +fn nv12_try_new_rejects_uv_stride_under_width() { + let (y, uv) = nv12_planes(); + let e = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 8).unwrap_err(); + assert!(matches!(e, Nv12FrameError::UvStrideTooSmall { .. })); +} + +#[test] +fn nv12_try_new_rejects_short_y_plane() { + let y = std::vec![0u8; 10]; + let uv = std::vec![128u8; 16 * 4]; + let e = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv12FrameError::YPlaneTooShort { .. })); +} + +#[test] +fn nv12_try_new_rejects_short_uv_plane() { + let y = std::vec![0u8; 16 * 8]; + let uv = std::vec![128u8; 8]; + let e = Nv12Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv12FrameError::UvPlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid Nv12Frame")] +fn nv12_new_panics_on_invalid() { + let y = std::vec![0u8; 10]; + let uv = std::vec![128u8; 16 * 4]; + let _ = Nv12Frame::new(&y, &uv, 16, 8, 16, 16); +} + +// ---- 32-bit overflow regressions -------------------------------------- +// +// `u32 * u32` can exceed `usize::MAX` only on 32-bit targets (wasm32, +// i686). Gate the tests so they actually run on those hosts under CI +// cross builds; on 64-bit they're trivially uninteresting (the +// product always fits). + +#[cfg(target_pointer_width = "32")] +#[test] +fn yuv420p_try_new_rejects_y_geometry_overflow() { + // 0x1_0000 * 0x1_0000 = 2^32, which overflows a 32-bit `usize` + // (max = 2^32 − 1). Even so the odd-width check passes, so we + // actually reach `checked_mul` and hit `GeometryOverflow`. + let big: u32 = 0x1_0000; + let y: [u8; 0] = []; + let u: [u8; 0] = []; + let v: [u8; 0] = []; + let e = Yuv420pFrame::try_new(&y, &u, &v, big, big, big, big / 2, big / 2).unwrap_err(); + assert!(matches!(e, Yuv420pFrameError::GeometryOverflow { .. })); +} + +#[cfg(target_pointer_width = "32")] +#[test] +fn nv12_try_new_rejects_geometry_overflow() { + let big: u32 = 0x1_0000; + let y: [u8; 0] = []; + let uv: [u8; 0] = []; + let e = Nv12Frame::try_new(&y, &uv, big, big, big, big).unwrap_err(); + assert!(matches!(e, Nv12FrameError::GeometryOverflow { .. })); +} + +// ---- Nv16Frame --------------------------------------------------------- +// +// 4:2:2: chroma is half-width, **full-height**. UV plane is `width * +// height` bytes (vs. NV12's `width * height / 2`). No height parity +// constraint. + +fn nv16_planes() -> (std::vec::Vec, std::vec::Vec) { + // 16×8 frame → UV is 8 chroma columns × 8 chroma rows = 16 bytes/row + // × 8 rows (not 4 — full height). + (std::vec![0u8; 16 * 8], std::vec![128u8; 16 * 8]) +} + +#[test] +fn nv16_try_new_accepts_valid_tight() { + let (y, uv) = nv16_planes(); + let f = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); + assert_eq!(f.uv_stride(), 16); +} + +#[test] +fn nv16_try_new_accepts_valid_padded_strides() { + let y = std::vec![0u8; 32 * 8]; + let uv = std::vec![128u8; 32 * 8]; + let f = Nv16Frame::try_new(&y, &uv, 16, 8, 32, 32).expect("valid"); + assert_eq!(f.y_stride(), 32); + assert_eq!(f.uv_stride(), 32); +} + +#[test] +fn nv16_try_new_rejects_zero_dim() { + let (y, uv) = nv16_planes(); + let e = Nv16Frame::try_new(&y, &uv, 0, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv16FrameError::ZeroDimension { .. })); +} + +#[test] +fn nv16_try_new_rejects_odd_width() { + let (y, uv) = nv16_planes(); + let e = Nv16Frame::try_new(&y, &uv, 15, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv16FrameError::OddWidth { width: 15 })); +} + +#[test] +fn nv16_try_new_accepts_odd_height() { + // 4:2:2 has no height parity restriction (chroma is full-height, + // 1:1 per Y row). A 640x481 NV16 frame should construct fine. + let y = std::vec![0u8; 640 * 481]; + let uv = std::vec![128u8; 640 * 481]; + let f = Nv16Frame::try_new(&y, &uv, 640, 481, 640, 640).expect("odd height valid"); + assert_eq!(f.height(), 481); + assert_eq!(f.width(), 640); +} + +#[test] +fn nv16_try_new_rejects_y_stride_under_width() { + let (y, uv) = nv16_planes(); + let e = Nv16Frame::try_new(&y, &uv, 16, 8, 8, 16).unwrap_err(); + assert!(matches!(e, Nv16FrameError::YStrideTooSmall { .. })); +} + +#[test] +fn nv16_try_new_rejects_uv_stride_under_width() { + let (y, uv) = nv16_planes(); + let e = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 8).unwrap_err(); + assert!(matches!(e, Nv16FrameError::UvStrideTooSmall { .. })); +} + +#[test] +fn nv16_try_new_rejects_short_y_plane() { + let y = std::vec![0u8; 10]; + let uv = std::vec![128u8; 16 * 8]; + let e = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv16FrameError::YPlaneTooShort { .. })); +} + +#[test] +fn nv16_try_new_rejects_short_uv_plane() { + let y = std::vec![0u8; 16 * 8]; + // NV12 would accept `16 * 4 = 64` bytes here; NV16 needs full + // height → this must fail. + let uv = std::vec![128u8; 16 * 4]; + let e = Nv16Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv16FrameError::UvPlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid Nv16Frame")] +fn nv16_new_panics_on_invalid() { + let y = std::vec![0u8; 10]; + let uv = std::vec![128u8; 16 * 8]; + let _ = Nv16Frame::new(&y, &uv, 16, 8, 16, 16); +} + +#[cfg(target_pointer_width = "32")] +#[test] +fn nv16_try_new_rejects_geometry_overflow() { + let big: u32 = 0x1_0000; + let y: [u8; 0] = []; + let uv: [u8; 0] = []; + let e = Nv16Frame::try_new(&y, &uv, big, big, big, big).unwrap_err(); + assert!(matches!(e, Nv16FrameError::GeometryOverflow { .. })); +} + +// ---- Nv24Frame --------------------------------------------------------- +// +// 4:4:4: chroma is full-width and full-height. UV plane is +// `2 * width * height` bytes. No width parity constraint. + +fn nv24_planes() -> (std::vec::Vec, std::vec::Vec) { + // 16×8 frame → UV is 16 chroma columns × 8 chroma rows = 32 bytes/row + // × 8 rows = 256 bytes. + (std::vec![0u8; 16 * 8], std::vec![128u8; 32 * 8]) +} + +#[test] +fn nv24_try_new_accepts_valid_tight() { + let (y, uv) = nv24_planes(); + let f = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 32).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); + assert_eq!(f.uv_stride(), 32); +} + +#[test] +fn nv24_try_new_accepts_odd_width() { + // 4:4:4 has no width parity constraint. 17×8 → UV plane = 34 * 8. + let y = std::vec![0u8; 17 * 8]; + let uv = std::vec![128u8; 34 * 8]; + let f = Nv24Frame::try_new(&y, &uv, 17, 8, 17, 34).expect("odd width valid"); + assert_eq!(f.width(), 17); +} + +#[test] +fn nv24_try_new_accepts_odd_height() { + let y = std::vec![0u8; 16 * 7]; + let uv = std::vec![128u8; 32 * 7]; + let f = Nv24Frame::try_new(&y, &uv, 16, 7, 16, 32).expect("odd height valid"); + assert_eq!(f.height(), 7); +} + +#[test] +fn nv24_try_new_rejects_zero_dim() { + let (y, uv) = nv24_planes(); + let e = Nv24Frame::try_new(&y, &uv, 0, 8, 16, 32).unwrap_err(); + assert!(matches!(e, Nv24FrameError::ZeroDimension { .. })); +} + +#[test] +fn nv24_try_new_rejects_y_stride_under_width() { + let (y, uv) = nv24_planes(); + let e = Nv24Frame::try_new(&y, &uv, 16, 8, 8, 32).unwrap_err(); + assert!(matches!(e, Nv24FrameError::YStrideTooSmall { .. })); +} + +#[test] +fn nv24_try_new_rejects_uv_stride_under_double_width() { + let (y, uv) = nv24_planes(); + // 4:4:4 requires uv_stride >= 2 * width (= 32). 16 is insufficient. + let e = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv24FrameError::UvStrideTooSmall { .. })); +} + +#[test] +fn nv24_try_new_rejects_short_y_plane() { + let y = std::vec![0u8; 10]; + let uv = std::vec![128u8; 32 * 8]; + let e = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 32).unwrap_err(); + assert!(matches!(e, Nv24FrameError::YPlaneTooShort { .. })); +} + +#[test] +fn nv24_try_new_rejects_short_uv_plane() { + let y = std::vec![0u8; 16 * 8]; + let uv = std::vec![128u8; 32]; // one row instead of 8 + let e = Nv24Frame::try_new(&y, &uv, 16, 8, 16, 32).unwrap_err(); + assert!(matches!(e, Nv24FrameError::UvPlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid Nv24Frame")] +fn nv24_new_panics_on_invalid() { + let y = std::vec![0u8; 10]; + let uv = std::vec![128u8; 32 * 8]; + let _ = Nv24Frame::new(&y, &uv, 16, 8, 16, 32); +} + +#[cfg(target_pointer_width = "32")] +#[test] +fn nv24_try_new_rejects_geometry_overflow() { + let big: u32 = 0x1_0000; + let y: [u8; 0] = []; + let uv: [u8; 0] = []; + // stride * height overflow path + let e = Nv24Frame::try_new(&y, &uv, big, big, big, big * 2).unwrap_err(); + assert!(matches!(e, Nv24FrameError::GeometryOverflow { .. })); +} + +#[test] +fn nv24_try_new_rejects_uv_width_overflow_u32() { + // `width * 2` overflows u32 → we report GeometryOverflow before + // even looking at uv_stride. + let y: [u8; 0] = []; + let uv: [u8; 0] = []; + // width >= 2^31 makes `width * 2` overflow u32. + let w: u32 = 0x8000_0000; + let e = Nv24Frame::try_new(&y, &uv, w, 1, w, 0).unwrap_err(); + assert!(matches!(e, Nv24FrameError::GeometryOverflow { .. })); +} + +// ---- Nv42Frame --------------------------------------------------------- +// +// Structurally identical to Nv24. Tests mirror the Nv24 set. + +fn nv42_planes() -> (std::vec::Vec, std::vec::Vec) { + (std::vec![0u8; 16 * 8], std::vec![128u8; 32 * 8]) +} + +#[test] +fn nv42_try_new_accepts_valid_tight() { + let (y, vu) = nv42_planes(); + let f = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 32).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.vu_stride(), 32); +} + +#[test] +fn nv42_try_new_accepts_odd_width() { + let y = std::vec![0u8; 17 * 8]; + let vu = std::vec![128u8; 34 * 8]; + let f = Nv42Frame::try_new(&y, &vu, 17, 8, 17, 34).expect("odd width valid"); + assert_eq!(f.width(), 17); +} + +#[test] +fn nv42_try_new_rejects_zero_dim() { + let (y, vu) = nv42_planes(); + let e = Nv42Frame::try_new(&y, &vu, 0, 8, 16, 32).unwrap_err(); + assert!(matches!(e, Nv42FrameError::ZeroDimension { .. })); +} + +#[test] +fn nv42_try_new_rejects_vu_stride_under_double_width() { + let (y, vu) = nv42_planes(); + let e = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv42FrameError::VuStrideTooSmall { .. })); +} + +#[test] +fn nv42_try_new_rejects_short_y_plane() { + let y = std::vec![0u8; 10]; + let vu = std::vec![128u8; 32 * 8]; + let e = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 32).unwrap_err(); + assert!(matches!(e, Nv42FrameError::YPlaneTooShort { .. })); +} + +#[test] +fn nv42_try_new_rejects_short_vu_plane() { + let y = std::vec![0u8; 16 * 8]; + let vu = std::vec![128u8; 32]; + let e = Nv42Frame::try_new(&y, &vu, 16, 8, 16, 32).unwrap_err(); + assert!(matches!(e, Nv42FrameError::VuPlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid Nv42Frame")] +fn nv42_new_panics_on_invalid() { + let y = std::vec![0u8; 10]; + let vu = std::vec![128u8; 32 * 8]; + let _ = Nv42Frame::new(&y, &vu, 16, 8, 16, 32); +} + +// ---- Nv21Frame --------------------------------------------------------- +// +// NV21 is structurally identical to NV12 (same plane count, same +// stride/size math) — only the byte order within the chroma plane +// differs. Validation tests mirror the NV12 set. Kernel-level +// equivalence with NV12-swapped-UV is tested in `src/row/arch/*`. + +fn nv21_planes() -> (std::vec::Vec, std::vec::Vec) { + // 16×8 frame → VU is 16 bytes × 4 chroma rows. + (std::vec![0u8; 16 * 8], std::vec![128u8; 16 * 4]) +} + +#[test] +fn nv21_try_new_accepts_valid_tight() { + let (y, vu) = nv21_planes(); + let f = Nv21Frame::try_new(&y, &vu, 16, 8, 16, 16).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); + assert_eq!(f.vu_stride(), 16); +} + +#[test] +fn nv21_try_new_accepts_odd_height() { + // Same concrete case as NV12 — 640x481. + let y = std::vec![0u8; 640 * 481]; + let vu = std::vec![128u8; 640 * 241]; + let f = Nv21Frame::try_new(&y, &vu, 640, 481, 640, 640).expect("odd height valid"); + assert_eq!(f.height(), 481); +} + +#[test] +fn nv21_try_new_rejects_odd_width() { + let (y, vu) = nv21_planes(); + let e = Nv21Frame::try_new(&y, &vu, 15, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv21FrameError::OddWidth { width: 15 })); +} + +#[test] +fn nv21_try_new_rejects_zero_dim() { + let (y, vu) = nv21_planes(); + let e = Nv21Frame::try_new(&y, &vu, 0, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv21FrameError::ZeroDimension { .. })); +} + +#[test] +fn nv21_try_new_rejects_vu_stride_under_width() { + let (y, vu) = nv21_planes(); + let e = Nv21Frame::try_new(&y, &vu, 16, 8, 16, 8).unwrap_err(); + assert!(matches!(e, Nv21FrameError::VuStrideTooSmall { .. })); +} + +#[test] +fn nv21_try_new_rejects_short_vu_plane() { + let y = std::vec![0u8; 16 * 8]; + let vu = std::vec![128u8; 8]; + let e = Nv21Frame::try_new(&y, &vu, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, Nv21FrameError::VuPlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid Nv21Frame")] +fn nv21_new_panics_on_invalid() { + let y = std::vec![0u8; 10]; + let vu = std::vec![128u8; 16 * 4]; + let _ = Nv21Frame::new(&y, &vu, 16, 8, 16, 16); +} + +#[cfg(target_pointer_width = "32")] +#[test] +fn nv21_try_new_rejects_geometry_overflow() { + let big: u32 = 0x1_0000; + let y: [u8; 0] = []; + let vu: [u8; 0] = []; + let e = Nv21Frame::try_new(&y, &vu, big, big, big, big).unwrap_err(); + assert!(matches!(e, Nv21FrameError::GeometryOverflow { .. })); +} + +// ---- Yuv420pFrame16 / Yuv420p10Frame ---------------------------------- +// +// Storage is `&[u16]` with sample-indexed strides. Validation mirrors +// the 8-bit [`Yuv420pFrame`] with the addition of the `BITS` guard. + +fn p10_planes() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { + // 16×8 frame, chroma 8×4. Y plane solid black (Y=0); UV planes + // neutral (UV=512 = 10‑bit chroma center). Exact sample values + // don't matter for the constructor tests that use this helper — + // they only look at shape, geometry errors, and the reported + // bits. + ( + std::vec![0u16; 16 * 8], + std::vec![512u16; 8 * 4], + std::vec![512u16; 8 * 4], + ) +} + +#[test] +fn yuv420p10_try_new_accepts_valid_tight() { + let (y, u, v) = p10_planes(); + let f = Yuv420p10Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); + assert_eq!(f.bits(), 10); +} + +#[test] +fn yuv420p10_try_new_accepts_odd_height() { + // 16x9 → chroma_height = 5. Y plane 16*9 = 144 samples, U/V 8*5 = 40. + let y = std::vec![0u16; 16 * 9]; + let u = std::vec![512u16; 8 * 5]; + let v = std::vec![512u16; 8 * 5]; + let f = Yuv420p10Frame::try_new(&y, &u, &v, 16, 9, 16, 8, 8).expect("odd height valid"); + assert_eq!(f.height(), 9); +} + +#[test] +fn yuv420p10_try_new_rejects_odd_width() { + let (y, u, v) = p10_planes(); + let e = Yuv420p10Frame::try_new(&y, &u, &v, 15, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrame16Error::OddWidth { width: 15 })); +} + +#[test] +fn yuv420p10_try_new_rejects_zero_dim() { + let (y, u, v) = p10_planes(); + let e = Yuv420p10Frame::try_new(&y, &u, &v, 0, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrame16Error::ZeroDimension { .. })); +} + +#[test] +fn yuv420p10_try_new_rejects_short_y_plane() { + let y = std::vec![0u16; 10]; + let u = std::vec![512u16; 8 * 4]; + let v = std::vec![512u16; 8 * 4]; + let e = Yuv420p10Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrame16Error::YPlaneTooShort { .. })); +} + +#[test] +fn yuv420p10_try_new_rejects_short_u_plane() { + let y = std::vec![0u16; 16 * 8]; + let u = std::vec![512u16; 4]; + let v = std::vec![512u16; 8 * 4]; + let e = Yuv420p10Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrame16Error::UPlaneTooShort { .. })); +} + +#[test] +fn yuv420p_frame16_try_new_rejects_unsupported_bits() { + // BITS must be in {9, 10, 12, 14, 16}. 11, 15, etc. are rejected + // before any plane math runs. + let y = std::vec![0u16; 16 * 8]; + let u = std::vec![128u16; 8 * 4]; + let v = std::vec![128u16; 8 * 4]; + let e = Yuv420pFrame16::<11>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::UnsupportedBits { bits: 11 } + )); + let e15 = Yuv420pFrame16::<15>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!( + e15, + Yuv420pFrame16Error::UnsupportedBits { bits: 15 } + )); +} + +#[test] +fn yuv420p16_try_new_accepts_12_14_and_16() { + let y = std::vec![0u16; 16 * 8]; + let u = std::vec![2048u16; 8 * 4]; + let v = std::vec![2048u16; 8 * 4]; + let f12 = Yuv420pFrame16::<12>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("12-bit valid"); + assert_eq!(f12.bits(), 12); + let f14 = Yuv420pFrame16::<14>::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("14-bit valid"); + assert_eq!(f14.bits(), 14); + let f16 = Yuv420p16Frame::try_new(&y, &u, &v, 16, 8, 16, 8, 8).expect("16-bit valid"); + assert_eq!(f16.bits(), 16); +} + +#[test] +fn yuv420p16_try_new_checked_accepts_full_u16_range() { + // At 16 bits the full u16 range is valid — max sample = 65535. + let y = std::vec![65535u16; 16 * 8]; + let u = std::vec![32768u16; 8 * 4]; + let v = std::vec![32768u16; 8 * 4]; + Yuv420p16Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8) + .expect("every u16 value is in range at 16 bits"); +} + +#[test] +fn p016_try_new_accepts_16bit() { + let y = std::vec![0xFFFFu16; 16 * 8]; + let uv = std::vec![0x8000u16; 16 * 4]; + let f = P016Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("P016 valid"); + assert_eq!(f.bits(), 16); +} + +#[test] +fn p016_try_new_checked_is_a_noop() { + // At BITS == 16 there are zero "low" bits to check — every u16 + // value is a valid P016 sample because `16 - BITS == 0`. The + // checked constructor therefore accepts everything. This pins + // that behavior in a test: at 16 bits the semantic distinction + // between P016 and yuv420p16le **cannot be detected** from + // sample values at all (no bit pattern is packing-specific). + let y = std::vec![0x1234u16; 16 * 8]; + let uv = std::vec![0x5678u16; 16 * 4]; + P016Frame::try_new_checked(&y, &uv, 16, 8, 16, 16) + .expect("every u16 passes the low-bits check at BITS == 16"); +} + +#[test] +fn pn_try_new_rejects_bits_other_than_10_12_16() { + let y = std::vec![0u16; 16 * 8]; + let uv = std::vec![0u16; 16 * 4]; + let e14 = PnFrame::<14>::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e14, PnFrameError::UnsupportedBits { bits: 14 })); + let e11 = PnFrame::<11>::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e11, PnFrameError::UnsupportedBits { bits: 11 })); +} + +#[test] +#[should_panic(expected = "invalid Yuv420pFrame16")] +fn yuv420p10_new_panics_on_invalid() { + let y = std::vec![0u16; 10]; + let u = std::vec![512u16; 8 * 4]; + let v = std::vec![512u16; 8 * 4]; + let _ = Yuv420p10Frame::new(&y, &u, &v, 16, 8, 16, 8, 8); +} + +#[cfg(target_pointer_width = "32")] +#[test] +fn yuv420p10_try_new_rejects_geometry_overflow() { + // Sample count overflow on 32-bit. Same rationale as the 8-bit + // version — strides are in `u16` elements here, so the same + // `0x1_0000 * 0x1_0000` product overflows `usize`. + let big: u32 = 0x1_0000; + let y: [u16; 0] = []; + let u: [u16; 0] = []; + let v: [u16; 0] = []; + let e = Yuv420p10Frame::try_new(&y, &u, &v, big, big, big, big / 2, big / 2).unwrap_err(); + assert!(matches!(e, Yuv420pFrame16Error::GeometryOverflow { .. })); +} + +#[test] +fn yuv420p10_try_new_checked_accepts_in_range_samples() { + // Same valid frame as `yuv420p10_try_new_accepts_valid_tight`, + // but run through the checked constructor. All samples live in + // the 10‑bit range. + let (y, u, v) = p10_planes(); + let f = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.bits(), 10); +} + +#[test] +fn yuv420p10_try_new_checked_rejects_y_high_bit_set() { + // A Y sample with bit 15 set — typical of `p010` packing where + // the 10 active bits sit in the high bits. `try_new` would + // accept this and let the SIMD kernels produce arch‑dependent + // garbage; `try_new_checked` catches it up front. + let mut y = std::vec![0u16; 16 * 8]; + y[3 * 16 + 5] = 0x8000; // bit 15 set → way above 1023 + let u = std::vec![512u16; 8 * 4]; + let v = std::vec![512u16; 8 * 4]; + let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + match e { + Yuv420pFrame16Error::SampleOutOfRange { + plane, + value, + max_valid, + .. + } => { + assert_eq!(plane, Yuv420pFrame16Plane::Y); + assert_eq!(value, 0x8000); + assert_eq!(max_valid, 1023); + } + other => panic!("expected SampleOutOfRange, got {other:?}"), + } +} + +#[test] +fn yuv420p10_try_new_checked_rejects_u_plane_sample() { + // Offending sample in the U plane — error must name U, not Y or V. + let y = std::vec![0u16; 16 * 8]; + let mut u = std::vec![512u16; 8 * 4]; + u[2 * 8 + 3] = 1024; // just above max + let v = std::vec![512u16; 8 * 4]; + let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::U, + value: 1024, + max_valid: 1023, + .. + } + )); +} + +#[test] +fn yuv420p10_try_new_checked_rejects_v_plane_sample() { + let y = std::vec![0u16; 16 * 8]; + let u = std::vec![512u16; 8 * 4]; + let mut v = std::vec![512u16; 8 * 4]; + v[8 + 7] = 0xFFFF; // all bits set + let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::V, + max_valid: 1023, + .. + } + )); +} + +#[test] +fn yuv420p10_try_new_checked_accepts_exact_max_sample() { + // Boundary: sample value == (1 << BITS) - 1 is valid. + let mut y = std::vec![0u16; 16 * 8]; + y[0] = 1023; + let u = std::vec![512u16; 8 * 4]; + let v = std::vec![512u16; 8 * 4]; + Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("1023 is in range"); +} + +#[test] +fn yuv420p10_try_new_checked_reports_geometry_errors_first() { + // If geometry is invalid, we never get to the sample scan — the + // same errors as `try_new` surface first. Prevents the checked + // path from doing unnecessary O(N) work on inputs that would + // fail for a simpler reason. + let y = std::vec![0u16; 10]; // Too small. + let u = std::vec![512u16; 8 * 4]; + let v = std::vec![512u16; 8 * 4]; + let e = Yuv420p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!(e, Yuv420pFrame16Error::YPlaneTooShort { .. })); +} + +// ---- P010Frame --------------------------------------------------------- +// +// Semi‑planar 10‑bit. Plane shape mirrors Nv12Frame (Y + interleaved +// UV) but sample width is `u16` with the 10 active bits in the +// **high** 10 of each element (`value << 6`). Strides are in +// samples, not bytes. + +fn p010_planes() -> (std::vec::Vec, std::vec::Vec) { + // 16×8 frame — UV plane carries 16 u16 × 4 chroma rows = 64 u16. + // P010 white Y = 1023 << 6 = 0xFFC0; neutral UV = 512 << 6 = 0x8000. + (std::vec![0xFFC0u16; 16 * 8], std::vec![0x8000u16; 16 * 4]) +} + +#[test] +fn p010_try_new_accepts_valid_tight() { + let (y, uv) = p010_planes(); + let f = P010Frame::try_new(&y, &uv, 16, 8, 16, 16).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); + assert_eq!(f.uv_stride(), 16); +} + +#[test] +fn p010_try_new_accepts_odd_height() { + // 640×481 — same concrete odd‑height case covered by NV12 / NV21. + let y = std::vec![0u16; 640 * 481]; + let uv = std::vec![0x8000u16; 640 * 241]; + let f = P010Frame::try_new(&y, &uv, 640, 481, 640, 640).expect("odd height valid"); + assert_eq!(f.height(), 481); +} + +#[test] +fn p010_try_new_rejects_odd_width() { + let (y, uv) = p010_planes(); + let e = P010Frame::try_new(&y, &uv, 15, 8, 16, 16).unwrap_err(); + assert!(matches!(e, PnFrameError::OddWidth { width: 15 })); +} + +#[test] +fn p010_try_new_rejects_zero_dim() { + let (y, uv) = p010_planes(); + let e = P010Frame::try_new(&y, &uv, 0, 8, 16, 16).unwrap_err(); + assert!(matches!(e, PnFrameError::ZeroDimension { .. })); +} + +#[test] +fn p010_try_new_rejects_y_stride_under_width() { + let (y, uv) = p010_planes(); + let e = P010Frame::try_new(&y, &uv, 16, 8, 8, 16).unwrap_err(); + assert!(matches!(e, PnFrameError::YStrideTooSmall { .. })); +} + +#[test] +fn p010_try_new_rejects_uv_stride_under_width() { + let (y, uv) = p010_planes(); + let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 8).unwrap_err(); + assert!(matches!(e, PnFrameError::UvStrideTooSmall { .. })); +} + +#[test] +fn p010_try_new_rejects_odd_uv_stride() { + // uv_stride = 17 passes the size check (>= width = 16) but is + // odd, which would mis-align the (U, V) pair on every other row. + let y = std::vec![0u16; 16 * 8]; + let uv = std::vec![0x8000u16; 17 * 4]; + let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 17).unwrap_err(); + assert!(matches!(e, PnFrameError::UvStrideOdd { uv_stride: 17 })); +} + +#[test] +fn p210_try_new_rejects_odd_uv_stride() { + // PnFrame422 chroma is half-width × full-height with 2 u16 per + // pair → uv_row_elems = width. Same odd-stride bug as P010. + let y = std::vec![0u16; 16 * 8]; + let uv = std::vec![0x8000u16; 17 * 8]; + let e = P210Frame::try_new(&y, &uv, 16, 8, 16, 17).unwrap_err(); + assert!(matches!(e, PnFrameError::UvStrideOdd { uv_stride: 17 })); +} + +#[test] +fn p410_try_new_rejects_odd_uv_stride() { + // PnFrame444 chroma is full-width × full-height with 2 u16 per + // pair → uv_row_elems = 2 * width = 32. uv_stride = 33 passes + // the size check but is odd. + let y = std::vec![0u16; 16 * 8]; + let uv = std::vec![0x8000u16; 33 * 8]; + let e = P410Frame::try_new(&y, &uv, 16, 8, 16, 33).unwrap_err(); + assert!(matches!(e, PnFrameError::UvStrideOdd { uv_stride: 33 })); +} + +#[test] +fn p010_try_new_rejects_short_y_plane() { + let y = std::vec![0u16; 10]; + let uv = std::vec![0x8000u16; 16 * 4]; + let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, PnFrameError::YPlaneTooShort { .. })); +} + +#[test] +fn p010_try_new_rejects_short_uv_plane() { + let y = std::vec![0u16; 16 * 8]; + let uv = std::vec![0x8000u16; 8]; + let e = P010Frame::try_new(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, PnFrameError::UvPlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid PnFrame")] +fn p010_new_panics_on_invalid() { + let y = std::vec![0u16; 10]; + let uv = std::vec![0x8000u16; 16 * 4]; + let _ = P010Frame::new(&y, &uv, 16, 8, 16, 16); +} + +#[cfg(target_pointer_width = "32")] +#[test] +fn p010_try_new_rejects_geometry_overflow() { + let big: u32 = 0x1_0000; + let y: [u16; 0] = []; + let uv: [u16; 0] = []; + let e = P010Frame::try_new(&y, &uv, big, big, big, big).unwrap_err(); + assert!(matches!(e, PnFrameError::GeometryOverflow { .. })); +} + +#[test] +fn p010_try_new_checked_accepts_shifted_samples() { + // Valid P010 samples: low 6 bits zero. + let (y, uv) = p010_planes(); + P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).expect("shifted samples valid"); +} + +#[test] +fn p010_try_new_checked_rejects_y_low_bits_set() { + // A Y sample with low 6 bits set — characteristic of yuv420p10le + // packing (value in low 10 bits) accidentally handed to the P010 + // constructor. `try_new_checked` catches this; plain `try_new` + // would let the kernel mask it down and produce wrong colors. + let mut y = std::vec![0xFFC0u16; 16 * 8]; + y[3 * 16 + 5] = 0x03FF; // 10-bit value in low bits — wrong packing + let uv = std::vec![0x8000u16; 16 * 4]; + let e = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); + match e { + PnFrameError::SampleLowBitsSet { plane, value, .. } => { + assert_eq!(plane, P010FramePlane::Y); + assert_eq!(value, 0x03FF); + } + other => panic!("expected SampleLowBitsSet, got {other:?}"), + } +} + +#[test] +fn p010_try_new_checked_rejects_uv_plane_sample() { + let y = std::vec![0xFFC0u16; 16 * 8]; + let mut uv = std::vec![0x8000u16; 16 * 4]; + uv[2 * 16 + 3] = 0x0001; // low bit set + let e = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!( + e, + PnFrameError::SampleLowBitsSet { + plane: P010FramePlane::Uv, + value: 0x0001, + .. + } + )); +} + +#[test] +fn p010_try_new_checked_reports_geometry_errors_first() { + let y = std::vec![0u16; 10]; // Too small. + let uv = std::vec![0x8000u16; 16 * 4]; + let e = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); + assert!(matches!(e, PnFrameError::YPlaneTooShort { .. })); +} + +/// Regression documenting a **known limitation** of +/// [`P010Frame::try_new_checked`]: the low‑6‑bits‑zero check is a +/// packing sanity check, not a provenance validator. A +/// `yuv420p10le` buffer whose samples all happen to be multiples +/// of 64 — e.g. `Y = 64` (limited‑range black, `0x0040`) and +/// `UV = 512` (neutral chroma, `0x0200`) — passes the check +/// silently, even though the layout is wrong and downstream P010 +/// kernels will produce incorrect output. +/// +/// The test asserts the check accepts these values so the limit +/// is visible in the test log; any future attempt to tighten the +/// constructor into a real provenance validator will need to +/// update or replace this test. +#[test] +fn p010_try_new_checked_accepts_ambiguous_yuv420p10le_samples() { + // `yuv420p10le`-style samples, all multiples of 64: low 6 bits + // are zero, so they pass the P010 sanity check even though this + // is wrong data for a P010 frame. + let y = std::vec![0x0040u16; 16 * 8]; // limited-range black in 10-bit low-packed + let uv = std::vec![0x0200u16; 16 * 4]; // neutral chroma in 10-bit low-packed + let f = P010Frame::try_new_checked(&y, &uv, 16, 8, 16, 16) + .expect("known limitation: low-6-bits-zero check cannot tell yuv420p10le from P010"); + assert_eq!(f.width(), 16); + // Downstream decoding of this frame would produce wrong colors + // (every `>> 6` extracts 1 from Y=0x0040 and 8 from UV=0x0200, + // which P010 kernels then bias/scale as if those were the 10-bit + // source values). That's accepted behavior — the type system, + // not `try_new_checked`, is what keeps yuv420p10le out of P010. +} + +#[test] +fn p012_try_new_checked_accepts_shifted_samples() { + // Valid P012 samples: low 4 bits zero (12-bit value << 4). + let y = std::vec![(2048u16) << 4; 16 * 8]; // 12-bit mid-gray shifted up + let uv = std::vec![(2048u16) << 4; 16 * 4]; + P012Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).expect("shifted samples valid"); +} + +#[test] +fn p012_try_new_checked_rejects_low_bits_set() { + // A Y sample with any of the low 4 bits set — e.g. yuv420p12le + // value 0x0ABC landing where P012 expects `value << 4`. The check + // catches samples like this that are obviously mispacked. + let mut y = std::vec![(2048u16) << 4; 16 * 8]; + y[3 * 16 + 5] = 0x0ABC; // low 4 bits = 0xC ≠ 0 + let uv = std::vec![(2048u16) << 4; 16 * 4]; + let e = P012Frame::try_new_checked(&y, &uv, 16, 8, 16, 16).unwrap_err(); + match e { + PnFrameError::SampleLowBitsSet { + plane, + value, + low_bits, + .. + } => { + assert_eq!(plane, PnFramePlane::Y); + assert_eq!(value, 0x0ABC); + assert_eq!(low_bits, 4); + } + other => panic!("expected SampleLowBitsSet, got {other:?}"), + } +} + +/// Regression documenting a **worse known limitation** of +/// [`P012Frame::try_new_checked`] compared to P010: because the +/// low‑bits check only has 4 bits to work with at `BITS == 12`, +/// every multiple‑of‑16 `yuv420p12le` value passes silently. The +/// practical impact is that common limited‑range flat‑region +/// content in real decoder output — `Y = 256` (limited‑range +/// black), `UV = 2048` (neutral chroma), `Y = 1024` (full black) +/// — is entirely invisible to this check. +/// +/// This test pins the limitation with a reproducible input so +/// that: +/// 1. Users reading the test suite can see the exact failure +/// mode for `try_new_checked` on 12‑bit data. +/// 2. Any future attempt to strengthen `try_new_checked` (e.g., +/// into a statistical provenance heuristic) has a concrete +/// input to validate against. +/// 3. The `PnFrame` docs' warning about this limitation has a +/// named test to point to. +/// +/// For P012, the type system (choosing [`P012Frame`] vs +/// [`Yuv420p12Frame`] at construction based on decoder metadata) +/// is the only reliable provenance guarantee. +#[test] +fn p012_try_new_checked_accepts_low_packed_flat_content_by_design() { + // All values are multiples of 16 — exactly the set that slips + // through a 4-low-bits-zero check. `yuv420p12le` limited-range + // black and neutral chroma both satisfy this. + let y = std::vec![0x0100u16; 16 * 8]; // Y = 256 (limited-range black), multiple of 16 + let uv = std::vec![0x0800u16; 16 * 4]; // UV = 2048 (neutral chroma), multiple of 16 + let f = P012Frame::try_new_checked(&y, &uv, 16, 8, 16, 16) + .expect("known limitation: 4-low-bits-zero check cannot tell yuv420p12le from P012"); + assert_eq!(f.width(), 16); + // Downstream P012 kernels would extract `>> 4` — giving Y=16 and + // UV=128 instead of the intended Y=256 and UV=2048. Silent color + // corruption. The type system, not `try_new_checked`, must + // guarantee provenance for 12-bit. +} + +// ---- Yuv422pFrame16::try_new_checked --------------------------------- + +fn p422_planes_10bit() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { + // Width 16, height 8 — 4:2:2 chroma is half-width, FULL-height. + let y = std::vec![64u16; 16 * 8]; + let u = std::vec![512u16; 8 * 8]; + let v = std::vec![512u16; 8 * 8]; + (y, u, v) +} + +#[test] +fn yuv422p10_try_new_checked_accepts_in_range_samples() { + let (y, u, v) = p422_planes_10bit(); + let f = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("valid 10-bit"); + assert_eq!(f.width(), 16); + assert_eq!(f.bits(), 10); +} + +#[test] +fn yuv422p10_try_new_checked_accepts_max_valid_value() { + // Exactly `(1 << 10) - 1 = 1023` must pass. + let y = std::vec![1023u16; 16 * 8]; + let u = std::vec![1023u16; 8 * 8]; + let v = std::vec![1023u16; 8 * 8]; + Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).expect("max valid passes"); +} + +#[test] +fn yuv422p10_try_new_checked_rejects_y_high_bit_set() { + let mut y = std::vec![0u16; 16 * 8]; + y[3 * 16 + 5] = 0x8000; + let u = std::vec![512u16; 8 * 8]; + let v = std::vec![512u16; 8 * 8]; + let e = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + match e { + Yuv420pFrame16Error::SampleOutOfRange { + plane, + value, + max_valid, + .. + } => { + assert_eq!(plane, Yuv420pFrame16Plane::Y); + assert_eq!(value, 0x8000); + assert_eq!(max_valid, 1023); + } + other => panic!("expected SampleOutOfRange, got {other:?}"), + } +} + +#[test] +fn yuv422p10_try_new_checked_rejects_u_plane_sample_in_full_height_chroma() { + // Crucial 4:2:2-specific test: the offending sample is on the + // last chroma row (row 7), which only exists because 4:2:2 + // chroma is full-height (8 rows). The 4:2:0 scan would stop at + // row 3. + let y = std::vec![0u16; 16 * 8]; + let mut u = std::vec![512u16; 8 * 8]; + u[7 * 8 + 3] = 1024; // last chroma row, just above max + let v = std::vec![512u16; 8 * 8]; + let e = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::U, + value: 1024, + max_valid: 1023, + .. + } + )); +} + +#[test] +fn yuv422p10_try_new_checked_rejects_v_plane_sample() { + let y = std::vec![0u16; 16 * 8]; + let u = std::vec![512u16; 8 * 8]; + let mut v = std::vec![512u16; 8 * 8]; + v[5 * 8 + 6] = 0xFFFF; + let e = Yuv422p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::V, + .. + } + )); +} + +#[test] +fn yuv422p12_try_new_checked_rejects_above_4095() { + let mut y = std::vec![2048u16; 16 * 8]; + y[0] = 4096; // just above 12-bit max + let u = std::vec![2048u16; 8 * 8]; + let v = std::vec![2048u16; 8 * 8]; + let e = Yuv422p12Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + value: 4096, + max_valid: 4095, + .. + } + )); +} + +#[test] +fn yuv422p16_try_new_checked_accepts_full_u16_range() { + // At 16 bits the full u16 range is valid — no scan needed. + let y = std::vec![65535u16; 16 * 8]; + let u = std::vec![32768u16; 8 * 8]; + let v = std::vec![32768u16; 8 * 8]; + Yuv422p16Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 8, 8) + .expect("every u16 value is in range at 16 bits"); +} + +// ---- Yuv444pFrame16::try_new_checked --------------------------------- + +fn p444_planes_10bit() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { + // 4:4:4: chroma is FULL-width, full-height (1:1 with Y). + let y = std::vec![64u16; 16 * 8]; + let u = std::vec![512u16; 16 * 8]; + let v = std::vec![512u16; 16 * 8]; + (y, u, v) +} + +#[test] +fn yuv444p10_try_new_checked_accepts_in_range_samples() { + let (y, u, v) = p444_planes_10bit(); + let f = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).expect("valid 10-bit"); + assert_eq!(f.width(), 16); + assert_eq!(f.bits(), 10); +} + +#[test] +fn yuv444p10_try_new_checked_accepts_max_valid_value() { + let y = std::vec![1023u16; 16 * 8]; + let u = std::vec![1023u16; 16 * 8]; + let v = std::vec![1023u16; 16 * 8]; + Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).expect("max valid passes"); +} + +#[test] +fn yuv444p10_try_new_checked_rejects_y_high_bit_set() { + let mut y = std::vec![0u16; 16 * 8]; + y[2 * 16 + 9] = 0x8000; + let u = std::vec![512u16; 16 * 8]; + let v = std::vec![512u16; 16 * 8]; + let e = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::Y, + value: 0x8000, + max_valid: 1023, + .. + } + )); +} + +#[test] +fn yuv444p10_try_new_checked_rejects_u_plane_sample_in_full_width_chroma() { + // 4:4:4-specific: the offending sample is in the FULL-WIDTH + // chroma plane, at column 13 (which doesn't exist in 4:2:0/4:2:2 + // half-width chroma). Forces the scan to extend across the full + // chroma width. + let y = std::vec![0u16; 16 * 8]; + let mut u = std::vec![512u16; 16 * 8]; + u[3 * 16 + 13] = 1024; + let v = std::vec![512u16; 16 * 8]; + let e = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::U, + value: 1024, + max_valid: 1023, + .. + } + )); +} + +#[test] +fn yuv444p10_try_new_checked_rejects_v_plane_sample() { + let y = std::vec![0u16; 16 * 8]; + let u = std::vec![512u16; 16 * 8]; + let mut v = std::vec![512u16; 16 * 8]; + v[7 * 16 + 15] = 0xFFFF; // last chroma sample + let e = Yuv444p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::V, + .. + } + )); +} + +#[test] +fn yuv444p14_try_new_checked_rejects_above_16383() { + let mut y = std::vec![8192u16; 16 * 8]; + y[42] = 16384; // just above 14-bit max + let u = std::vec![8192u16; 16 * 8]; + let v = std::vec![8192u16; 16 * 8]; + let e = Yuv444p14Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + value: 16384, + max_valid: 16383, + .. + } + )); +} + +#[test] +fn yuv444p16_try_new_checked_accepts_full_u16_range() { + let y = std::vec![65535u16; 16 * 8]; + let u = std::vec![32768u16; 16 * 8]; + let v = std::vec![32768u16; 16 * 8]; + Yuv444p16Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16) + .expect("every u16 value is in range at 16 bits"); +} + +// ---- Yuv440p10/12 checked-constructor tests --------------------------- + +fn p440_planes_10bit() -> (std::vec::Vec, std::vec::Vec, std::vec::Vec) { + // 4:4:0: chroma is FULL-width × HALF-height (8 / 2 = 4 chroma rows). + let y = std::vec![64u16; 16 * 8]; + let u = std::vec![512u16; 16 * 4]; + let v = std::vec![512u16; 16 * 4]; + (y, u, v) +} + +#[test] +fn yuv440p10_try_new_checked_accepts_in_range_samples() { + let (y, u, v) = p440_planes_10bit(); + let f = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).expect("valid 10-bit"); + assert_eq!(f.width(), 16); + assert_eq!(f.bits(), 10); +} + +#[test] +fn yuv440p10_try_new_checked_rejects_y_high_bit_set() { + let mut y = std::vec![0u16; 16 * 8]; + y[2 * 16 + 9] = 0x8000; + let u = std::vec![512u16; 16 * 4]; + let v = std::vec![512u16; 16 * 4]; + let e = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::Y, + value: 0x8000, + max_valid: 1023, + .. + } + )); +} + +#[test] +fn yuv440p10_try_new_checked_rejects_u_plane_sample_in_full_width_chroma() { + // 4:4:0-specific: chroma is full-width × half-height. Plant the + // bad sample at column 13 (would be out of range for half-width + // 4:2:0/4:2:2 chroma) on the last chroma row (index 3 for height + // 8 ⇒ 4 chroma rows). + let y = std::vec![0u16; 16 * 8]; + let mut u = std::vec![512u16; 16 * 4]; + u[3 * 16 + 13] = 1024; + let v = std::vec![512u16; 16 * 4]; + let e = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::U, + value: 1024, + max_valid: 1023, + .. + } + )); +} + +#[test] +fn yuv440p10_try_new_checked_rejects_v_plane_sample() { + let y = std::vec![0u16; 16 * 8]; + let u = std::vec![512u16; 16 * 4]; + let mut v = std::vec![512u16; 16 * 4]; + v[3 * 16 + 15] = 0xFFFF; // last chroma sample of the last chroma row + let e = Yuv440p10Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + plane: Yuv420pFrame16Plane::V, + .. + } + )); +} + +#[test] +fn yuv440p12_try_new_checked_rejects_above_4095() { + let mut y = std::vec![2048u16; 16 * 8]; + y[42] = 4096; // just above 12-bit max + let u = std::vec![2048u16; 16 * 4]; + let v = std::vec![2048u16; 16 * 4]; + let e = Yuv440p12Frame::try_new_checked(&y, &u, &v, 16, 8, 16, 16, 16).unwrap_err(); + assert!(matches!( + e, + Yuv420pFrame16Error::SampleOutOfRange { + value: 4096, + max_valid: 4095, + .. + } + )); +} + +// ----- BayerFrame (8-bit) ----- + +#[test] +fn bayer_try_new_accepts_valid_tight() { + let data = std::vec![0u8; 16 * 8]; + let f = BayerFrame::try_new(&data, 16, 8, 16).expect("valid"); + assert_eq!(f.width(), 16); + assert_eq!(f.height(), 8); + assert_eq!(f.stride(), 16); +} + +#[test] +fn bayer_try_new_accepts_padded_stride() { + let data = std::vec![0u8; 24 * 8]; + let f = BayerFrame::try_new(&data, 16, 8, 24).expect("padded stride valid"); + assert_eq!(f.stride(), 24); +} + +#[test] +fn bayer_try_new_rejects_zero_dim() { + let data = std::vec![0u8; 16 * 8]; + let e = BayerFrame::try_new(&data, 0, 8, 16).unwrap_err(); + assert!(matches!(e, BayerFrameError::ZeroDimension { .. })); + let e = BayerFrame::try_new(&data, 16, 0, 16).unwrap_err(); + assert!(matches!(e, BayerFrameError::ZeroDimension { .. })); +} + +#[test] +fn bayer_try_new_accepts_odd_width() { + // Cropped Bayer planes can have odd dimensions; the kernel + // handles partial 2×2 tiles via edge clamping. + let data = std::vec![0u8; 15 * 8]; + let f = BayerFrame::try_new(&data, 15, 8, 15).expect("odd width valid"); + assert_eq!(f.width(), 15); +} + +#[test] +fn bayer_try_new_accepts_odd_height() { + let data = std::vec![0u8; 16 * 7]; + let f = BayerFrame::try_new(&data, 16, 7, 16).expect("odd height valid"); + assert_eq!(f.height(), 7); +} + +#[test] +fn bayer_try_new_accepts_odd_width_and_height() { + let data = std::vec![0u8; 15 * 7]; + let f = BayerFrame::try_new(&data, 15, 7, 15).expect("odd both valid"); + assert_eq!(f.width(), 15); + assert_eq!(f.height(), 7); +} + +#[test] +fn bayer_try_new_accepts_1x1() { + let data = std::vec![42u8]; + let f = BayerFrame::try_new(&data, 1, 1, 1).expect("1x1 valid"); + assert_eq!(f.width(), 1); + assert_eq!(f.height(), 1); +} + +#[test] +fn bayer_try_new_rejects_stride_under_width() { + let data = std::vec![0u8; 16 * 8]; + let e = BayerFrame::try_new(&data, 16, 8, 8).unwrap_err(); + assert!(matches!(e, BayerFrameError::StrideTooSmall { .. })); +} + +#[test] +fn bayer_try_new_rejects_short_plane() { + let data = std::vec![0u8; 10]; + let e = BayerFrame::try_new(&data, 16, 8, 16).unwrap_err(); + assert!(matches!(e, BayerFrameError::PlaneTooShort { .. })); +} + +#[test] +#[should_panic(expected = "invalid BayerFrame")] +fn bayer_new_panics_on_invalid() { + let data = std::vec![0u8; 10]; + let _ = BayerFrame::new(&data, 16, 8, 16); +} + +// ----- BayerFrame16 (high-bit-depth) ----- + +#[test] +fn bayer16_try_new_rejects_unsupported_bits() { + let data = std::vec![0u16; 16 * 8]; + let e = BayerFrame16::<11>::try_new(&data, 16, 8, 16).unwrap_err(); + assert!(matches!(e, BayerFrame16Error::UnsupportedBits { bits: 11 })); + let e = BayerFrame16::<8>::try_new(&data, 16, 8, 16).unwrap_err(); + assert!(matches!(e, BayerFrame16Error::UnsupportedBits { bits: 8 })); +} + +#[test] +fn bayer16_try_new_accepts_each_supported_bits() { + let data = std::vec![0u16; 16 * 8]; + Bayer10Frame::try_new(&data, 16, 8, 16).expect("10"); + Bayer12Frame::try_new(&data, 16, 8, 16).expect("12"); + Bayer14Frame::try_new(&data, 16, 8, 16).expect("14"); + Bayer16Frame::try_new(&data, 16, 8, 16).expect("16"); +} + +#[test] +fn bayer16_try_new_accepts_odd_dims() { + let data = std::vec![0u16; 15 * 7]; + let f = Bayer12Frame::try_new(&data, 15, 7, 15).expect("odd both valid"); + assert_eq!(f.width(), 15); + assert_eq!(f.height(), 7); +} + +#[test] +fn bayer16_try_new_accepts_low_packed_12bit() { + // 12-bit low-packed: every value ≤ 4095 is valid. + let mut data = std::vec![2048u16; 16 * 8]; + data[7] = 4095; // max valid 12-bit + data[42] = 0; // black + Bayer12Frame::try_new(&data, 16, 8, 16).expect("12-bit low-packed"); +} + +#[test] +fn bayer16_try_new_rejects_above_max_at_12bit() { + let mut data = std::vec![2048u16; 16 * 8]; + data[42] = 4096; // just above 12-bit max + let e = Bayer12Frame::try_new(&data, 16, 8, 16).unwrap_err(); + assert!(matches!( + e, + BayerFrame16Error::SampleOutOfRange { + index: 42, + value: 4096, + max_valid: 4095, + } + )); +} + +#[test] +fn bayer16_try_new_rejects_above_max_at_10bit() { + let mut data = std::vec![512u16; 16 * 8]; + data[3] = 1024; // just above 10-bit max + let e = Bayer10Frame::try_new(&data, 16, 8, 16).unwrap_err(); + assert!(matches!( + e, + BayerFrame16Error::SampleOutOfRange { + index: 3, + value: 1024, + max_valid: 1023, + } + )); +} + +#[test] +fn bayer16_try_new_accepts_full_u16_range_at_16bit() { + // At BITS=16 every u16 is valid. + let mut data = std::vec![0u16; 16 * 8]; + data[7] = 0xFFFF; + data[42] = 0x1234; + Bayer16Frame::try_new(&data, 16, 8, 16).expect("any u16 valid at 16-bit"); +} + +#[test] +#[should_panic(expected = "invalid BayerFrame16")] +fn bayer16_new_panics_on_invalid() { + let data = std::vec![0u16; 10]; + let _ = Bayer12Frame::new(&data, 16, 8, 16); +} diff --git a/src/raw/bayer.rs b/src/raw/bayer.rs index 3562daa..950a16e 100644 --- a/src/raw/bayer.rs +++ b/src/raw/bayer.rs @@ -196,398 +196,4 @@ pub fn bayer_to( #[cfg(all(test, feature = "std"))] #[cfg(any(feature = "std", feature = "alloc"))] -mod tests { - use super::*; - use crate::row::bayer_to_rgb_row; - use core::convert::Infallible; - - /// Test sink that captures every output row into a single packed - /// RGB buffer the test owns. Calls the public dispatcher with - /// SIMD turned off (only scalar is wired up today). - struct CaptureRgb<'a> { - out: &'a mut [u8], - width: u32, - } - - impl PixelSink for CaptureRgb<'_> { - type Input<'b> = BayerRow<'b>; - type Error = Infallible; - - fn begin_frame(&mut self, width: u32, _height: u32) -> Result<(), Self::Error> { - self.width = width; - Ok(()) - } - - fn process(&mut self, row: BayerRow<'_>) -> Result<(), Self::Error> { - let row_idx = row.row(); - let w = self.width as usize; - let off = row_idx * w * 3; - let dst = &mut self.out[off..off + 3 * w]; - bayer_to_rgb_row( - row.above(), - row.mid(), - row.below(), - row.row_parity(), - row.pattern(), - row.demosaic(), - row.m(), - dst, - false, - ); - Ok(()) - } - } - - impl BayerSink for CaptureRgb<'_> {} - - /// Build an RGGB Bayer plane from per-channel solid values. Pattern: - /// row 0 = R G R G ..., row 1 = G B G B ..., row 2 = R G R G, ... - fn solid_rggb(width: u32, height: u32, r: u8, g: u8, b: u8) -> std::vec::Vec { - let w = width as usize; - let h = height as usize; - let mut data = std::vec![0u8; w * h]; - for y in 0..h { - for x in 0..w { - data[y * w + x] = match (y & 1, x & 1) { - (0, 0) => r, - (0, 1) => g, - (1, 0) => g, - (1, 1) => b, - _ => unreachable!(), - }; - } - } - data - } - - /// Assert every output pixel — **including the borders** — - /// matches the expected RGB triple. Mirror-by-2 boundary handling - /// preserves CFA parity, so a solid-channel Bayer mosaic stays - /// solid across the full frame (no clamp-induced channel bleed - /// at the edges or corners). - fn assert_full_frame(rgb: &[u8], w: u32, h: u32, expect: (u8, u8, u8)) { - let w = w as usize; - let h = h as usize; - for y in 0..h { - for x in 0..w { - let i = (y * w + x) * 3; - assert_eq!(rgb[i], expect.0, "px ({x},{y}) R"); - assert_eq!(rgb[i + 1], expect.1, "px ({x},{y}) G"); - assert_eq!(rgb[i + 2], expect.2, "px ({x},{y}) B"); - } - } - } - - #[test] - fn bayer_solid_red_rggb_neutral_wb_identity_ccm_yields_red() { - let (w, h) = (8u32, 6u32); - let raw = solid_rggb(w, h, 255, 0, 0); - let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame(&rgb, w, h, (255, 0, 0)); - } - - #[test] - fn bayer_solid_green_rggb_yields_green() { - let (w, h) = (8u32, 6u32); - let raw = solid_rggb(w, h, 0, 255, 0); - let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame(&rgb, w, h, (0, 255, 0)); - } - - #[test] - fn bayer_solid_blue_rggb_yields_blue() { - let (w, h) = (8u32, 6u32); - let raw = solid_rggb(w, h, 0, 0, 255); - let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame(&rgb, w, h, (0, 0, 255)); - } - - #[test] - fn bayer_uniform_byte_yields_uniform_output() { - // Every byte = 200; every demosaic site reads 200 in every - // neighbor (clamps included), so all output channels = 200 - // even at edges. Smoke test for the kernel arithmetic itself, - // independent of pattern phase. - let (w, h) = (8u32, 6u32); - let raw = std::vec![200u8; (w * h) as usize]; - let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - for &b in &rgb { - assert_eq!(b, 200); - } - } - - #[test] - fn bayer_pattern_swap_red_to_blue() { - // RGGB plane filled to look red, decoded with BGGR pattern, - // should come out blue at interior sites (R↔B swap). - let (w, h) = (8u32, 6u32); - let raw = solid_rggb(w, h, 255, 0, 0); - let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Bggr, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame(&rgb, w, h, (0, 0, 255)); - } - - #[test] - fn bayer_walker_calls_sink_once_per_row() { - struct CountSink { - rows: u32, - } - impl PixelSink for CountSink { - type Input<'a> = BayerRow<'a>; - type Error = Infallible; - fn process(&mut self, _row: BayerRow<'_>) -> Result<(), Self::Error> { - self.rows += 1; - Ok(()) - } - } - impl BayerSink for CountSink {} - - let (w, h) = (8u32, 6u32); - let raw = std::vec![0u8; (w * h) as usize]; - let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); - let mut sink = CountSink { rows: 0 }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_eq!(sink.rows, h); - } - - #[test] - fn bayer_walker_handles_odd_width_and_height_full_frame() { - // 15×7 RGGB-tiled solid red. Mirror-by-2 boundary handling - // means every output pixel — interior and border — should - // match the expected channel. - let (w, h) = (15u32, 7u32); - let raw = solid_rggb(w, h, 255, 0, 0); - let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame(&rgb, w, h, (255, 0, 0)); - } - - #[test] - fn bayer_walker_handles_2x2_minimum_tile() { - // 2×2 RGGB-filled red. Smallest frame that still has a - // complete CFA tile. Mirror-by-2 maps `row -1 → row 1` and - // `row 2 → row 0`, so each row of the 2-row frame uses the - // other row as both `above` and `below`. Same for columns. - // Full frame should be solid red. - let raw = solid_rggb(2, 2, 255, 0, 0); - let frame = BayerFrame::try_new(&raw, 2, 2, 2).unwrap(); - let mut rgb = std::vec![0u8; 2 * 2 * 3]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame(&rgb, 2, 2, (255, 0, 0)); - } - - #[test] - fn bayer_walker_handles_1x1() { - // 1×1 corner case — every "neighbor" clamps to the single - // sample. Demosaic must run without panicking. - let raw = std::vec![123u8]; - let frame = BayerFrame::try_new(&raw, 1, 1, 1).unwrap(); - let mut rgb = std::vec![0u8; 3]; - let mut sink = CaptureRgb { - out: &mut rgb, - width: 0, - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - // Single R-site (RGGB at (0,0) = R): output R = 123, G/B - // averaged from the same sample = 123. - assert_eq!(rgb, std::vec![123, 123, 123]); - } - - /// Asserts the documented mirror-by-2 boundary contract: at the - /// top edge `above` is `mid_row(1)`, at the bottom edge `below` - /// is `mid_row(h - 2)`. A custom sink that captures the row - /// borrows directly can verify this without re-running the - /// kernel. - #[test] - fn bayer_walker_supplies_mirror_by_2_row_borrows() { - /// Captures the first byte of `above` and `below` for each row. - struct EdgeCapture { - above_first: std::vec::Vec, - below_first: std::vec::Vec, - } - impl PixelSink for EdgeCapture { - type Input<'a> = BayerRow<'a>; - type Error = Infallible; - fn process(&mut self, row: BayerRow<'_>) -> Result<(), Self::Error> { - self.above_first.push(row.above()[0]); - self.below_first.push(row.below()[0]); - Ok(()) - } - } - impl BayerSink for EdgeCapture {} - - // 4×4 plane where every row's first byte is the row index. So - // mid_row(r)[0] == r, and mirror-by-2 should produce - // above_first = [1, 0, 1, 2] and below_first = [1, 2, 3, 2]. - let raw: std::vec::Vec = (0..16u8).map(|i| i / 4).collect(); - let frame = BayerFrame::try_new(&raw, 4, 4, 4).unwrap(); - let mut sink = EdgeCapture { - above_first: std::vec::Vec::new(), - below_first: std::vec::Vec::new(), - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - // row 0: above = mid_row(1), below = mid_row(1) - // row 1: above = mid_row(0), below = mid_row(2) - // row 2: above = mid_row(1), below = mid_row(3) - // row 3: above = mid_row(2), below = mid_row(2) (mirror-by-2) - assert_eq!(sink.above_first, std::vec![1u8, 0, 1, 2]); - assert_eq!(sink.below_first, std::vec![1u8, 2, 3, 2]); - } - - /// Same contract test for `height < 2` — falls back to replicate - /// (no mirror partner exists). - #[test] - fn bayer_walker_falls_back_to_replicate_when_height_below_2() { - struct EdgeCapture { - above_first: std::vec::Vec, - below_first: std::vec::Vec, - } - impl PixelSink for EdgeCapture { - type Input<'a> = BayerRow<'a>; - type Error = Infallible; - fn process(&mut self, row: BayerRow<'_>) -> Result<(), Self::Error> { - self.above_first.push(row.above()[0]); - self.below_first.push(row.below()[0]); - Ok(()) - } - } - impl BayerSink for EdgeCapture {} - - let raw = std::vec![42u8; 4]; // 4 columns, 1 row - let frame = BayerFrame::try_new(&raw, 4, 1, 4).unwrap(); - let mut sink = EdgeCapture { - above_first: std::vec::Vec::new(), - below_first: std::vec::Vec::new(), - }; - bayer_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - // h=1: replicate fallback. above = below = mid = 42. - assert_eq!(sink.above_first, std::vec![42u8]); - assert_eq!(sink.below_first, std::vec![42u8]); - } -} +mod tests; diff --git a/src/raw/bayer/tests.rs b/src/raw/bayer/tests.rs new file mode 100644 index 0000000..5ff3656 --- /dev/null +++ b/src/raw/bayer/tests.rs @@ -0,0 +1,393 @@ +use super::*; +use crate::row::bayer_to_rgb_row; +use core::convert::Infallible; + +/// Test sink that captures every output row into a single packed +/// RGB buffer the test owns. Calls the public dispatcher with +/// SIMD turned off (only scalar is wired up today). +struct CaptureRgb<'a> { + out: &'a mut [u8], + width: u32, +} + +impl PixelSink for CaptureRgb<'_> { + type Input<'b> = BayerRow<'b>; + type Error = Infallible; + + fn begin_frame(&mut self, width: u32, _height: u32) -> Result<(), Self::Error> { + self.width = width; + Ok(()) + } + + fn process(&mut self, row: BayerRow<'_>) -> Result<(), Self::Error> { + let row_idx = row.row(); + let w = self.width as usize; + let off = row_idx * w * 3; + let dst = &mut self.out[off..off + 3 * w]; + bayer_to_rgb_row( + row.above(), + row.mid(), + row.below(), + row.row_parity(), + row.pattern(), + row.demosaic(), + row.m(), + dst, + false, + ); + Ok(()) + } +} + +impl BayerSink for CaptureRgb<'_> {} + +/// Build an RGGB Bayer plane from per-channel solid values. Pattern: +/// row 0 = R G R G ..., row 1 = G B G B ..., row 2 = R G R G, ... +fn solid_rggb(width: u32, height: u32, r: u8, g: u8, b: u8) -> std::vec::Vec { + let w = width as usize; + let h = height as usize; + let mut data = std::vec![0u8; w * h]; + for y in 0..h { + for x in 0..w { + data[y * w + x] = match (y & 1, x & 1) { + (0, 0) => r, + (0, 1) => g, + (1, 0) => g, + (1, 1) => b, + _ => unreachable!(), + }; + } + } + data +} + +/// Assert every output pixel — **including the borders** — +/// matches the expected RGB triple. Mirror-by-2 boundary handling +/// preserves CFA parity, so a solid-channel Bayer mosaic stays +/// solid across the full frame (no clamp-induced channel bleed +/// at the edges or corners). +fn assert_full_frame(rgb: &[u8], w: u32, h: u32, expect: (u8, u8, u8)) { + let w = w as usize; + let h = h as usize; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) * 3; + assert_eq!(rgb[i], expect.0, "px ({x},{y}) R"); + assert_eq!(rgb[i + 1], expect.1, "px ({x},{y}) G"); + assert_eq!(rgb[i + 2], expect.2, "px ({x},{y}) B"); + } + } +} + +#[test] +fn bayer_solid_red_rggb_neutral_wb_identity_ccm_yields_red() { + let (w, h) = (8u32, 6u32); + let raw = solid_rggb(w, h, 255, 0, 0); + let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame(&rgb, w, h, (255, 0, 0)); +} + +#[test] +fn bayer_solid_green_rggb_yields_green() { + let (w, h) = (8u32, 6u32); + let raw = solid_rggb(w, h, 0, 255, 0); + let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame(&rgb, w, h, (0, 255, 0)); +} + +#[test] +fn bayer_solid_blue_rggb_yields_blue() { + let (w, h) = (8u32, 6u32); + let raw = solid_rggb(w, h, 0, 0, 255); + let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame(&rgb, w, h, (0, 0, 255)); +} + +#[test] +fn bayer_uniform_byte_yields_uniform_output() { + // Every byte = 200; every demosaic site reads 200 in every + // neighbor (clamps included), so all output channels = 200 + // even at edges. Smoke test for the kernel arithmetic itself, + // independent of pattern phase. + let (w, h) = (8u32, 6u32); + let raw = std::vec![200u8; (w * h) as usize]; + let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + for &b in &rgb { + assert_eq!(b, 200); + } +} + +#[test] +fn bayer_pattern_swap_red_to_blue() { + // RGGB plane filled to look red, decoded with BGGR pattern, + // should come out blue at interior sites (R↔B swap). + let (w, h) = (8u32, 6u32); + let raw = solid_rggb(w, h, 255, 0, 0); + let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Bggr, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame(&rgb, w, h, (0, 0, 255)); +} + +#[test] +fn bayer_walker_calls_sink_once_per_row() { + struct CountSink { + rows: u32, + } + impl PixelSink for CountSink { + type Input<'a> = BayerRow<'a>; + type Error = Infallible; + fn process(&mut self, _row: BayerRow<'_>) -> Result<(), Self::Error> { + self.rows += 1; + Ok(()) + } + } + impl BayerSink for CountSink {} + + let (w, h) = (8u32, 6u32); + let raw = std::vec![0u8; (w * h) as usize]; + let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); + let mut sink = CountSink { rows: 0 }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_eq!(sink.rows, h); +} + +#[test] +fn bayer_walker_handles_odd_width_and_height_full_frame() { + // 15×7 RGGB-tiled solid red. Mirror-by-2 boundary handling + // means every output pixel — interior and border — should + // match the expected channel. + let (w, h) = (15u32, 7u32); + let raw = solid_rggb(w, h, 255, 0, 0); + let frame = BayerFrame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame(&rgb, w, h, (255, 0, 0)); +} + +#[test] +fn bayer_walker_handles_2x2_minimum_tile() { + // 2×2 RGGB-filled red. Smallest frame that still has a + // complete CFA tile. Mirror-by-2 maps `row -1 → row 1` and + // `row 2 → row 0`, so each row of the 2-row frame uses the + // other row as both `above` and `below`. Same for columns. + // Full frame should be solid red. + let raw = solid_rggb(2, 2, 255, 0, 0); + let frame = BayerFrame::try_new(&raw, 2, 2, 2).unwrap(); + let mut rgb = std::vec![0u8; 2 * 2 * 3]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame(&rgb, 2, 2, (255, 0, 0)); +} + +#[test] +fn bayer_walker_handles_1x1() { + // 1×1 corner case — every "neighbor" clamps to the single + // sample. Demosaic must run without panicking. + let raw = std::vec![123u8]; + let frame = BayerFrame::try_new(&raw, 1, 1, 1).unwrap(); + let mut rgb = std::vec![0u8; 3]; + let mut sink = CaptureRgb { + out: &mut rgb, + width: 0, + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + // Single R-site (RGGB at (0,0) = R): output R = 123, G/B + // averaged from the same sample = 123. + assert_eq!(rgb, std::vec![123, 123, 123]); +} + +/// Asserts the documented mirror-by-2 boundary contract: at the +/// top edge `above` is `mid_row(1)`, at the bottom edge `below` +/// is `mid_row(h - 2)`. A custom sink that captures the row +/// borrows directly can verify this without re-running the +/// kernel. +#[test] +fn bayer_walker_supplies_mirror_by_2_row_borrows() { + /// Captures the first byte of `above` and `below` for each row. + struct EdgeCapture { + above_first: std::vec::Vec, + below_first: std::vec::Vec, + } + impl PixelSink for EdgeCapture { + type Input<'a> = BayerRow<'a>; + type Error = Infallible; + fn process(&mut self, row: BayerRow<'_>) -> Result<(), Self::Error> { + self.above_first.push(row.above()[0]); + self.below_first.push(row.below()[0]); + Ok(()) + } + } + impl BayerSink for EdgeCapture {} + + // 4×4 plane where every row's first byte is the row index. So + // mid_row(r)[0] == r, and mirror-by-2 should produce + // above_first = [1, 0, 1, 2] and below_first = [1, 2, 3, 2]. + let raw: std::vec::Vec = (0..16u8).map(|i| i / 4).collect(); + let frame = BayerFrame::try_new(&raw, 4, 4, 4).unwrap(); + let mut sink = EdgeCapture { + above_first: std::vec::Vec::new(), + below_first: std::vec::Vec::new(), + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + // row 0: above = mid_row(1), below = mid_row(1) + // row 1: above = mid_row(0), below = mid_row(2) + // row 2: above = mid_row(1), below = mid_row(3) + // row 3: above = mid_row(2), below = mid_row(2) (mirror-by-2) + assert_eq!(sink.above_first, std::vec![1u8, 0, 1, 2]); + assert_eq!(sink.below_first, std::vec![1u8, 2, 3, 2]); +} + +/// Same contract test for `height < 2` — falls back to replicate +/// (no mirror partner exists). +#[test] +fn bayer_walker_falls_back_to_replicate_when_height_below_2() { + struct EdgeCapture { + above_first: std::vec::Vec, + below_first: std::vec::Vec, + } + impl PixelSink for EdgeCapture { + type Input<'a> = BayerRow<'a>; + type Error = Infallible; + fn process(&mut self, row: BayerRow<'_>) -> Result<(), Self::Error> { + self.above_first.push(row.above()[0]); + self.below_first.push(row.below()[0]); + Ok(()) + } + } + impl BayerSink for EdgeCapture {} + + let raw = std::vec![42u8; 4]; // 4 columns, 1 row + let frame = BayerFrame::try_new(&raw, 4, 1, 4).unwrap(); + let mut sink = EdgeCapture { + above_first: std::vec::Vec::new(), + below_first: std::vec::Vec::new(), + }; + bayer_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + // h=1: replicate fallback. above = below = mid = 42. + assert_eq!(sink.above_first, std::vec![42u8]); + assert_eq!(sink.below_first, std::vec![42u8]); +} diff --git a/src/raw/bayer16.rs b/src/raw/bayer16.rs index 2750e50..20030b2 100644 --- a/src/raw/bayer16.rs +++ b/src/raw/bayer16.rs @@ -220,479 +220,4 @@ pub fn bayer16_to>( #[cfg(all(test, feature = "std"))] #[cfg(any(feature = "std", feature = "alloc"))] -mod tests { - use super::*; - use crate::{ - frame::Bayer12Frame, - row::{bayer16_to_rgb_row, bayer16_to_rgb_u16_row}, - }; - use core::convert::Infallible; - - /// Sink that walks each `BayerRow16` through the public - /// u8-output dispatcher with SIMD off. - struct CaptureRgbU8<'a, const BITS: u32> { - out: &'a mut [u8], - width: u32, - } - - impl PixelSink for CaptureRgbU8<'_, BITS> { - type Input<'b> = BayerRow16<'b, BITS>; - type Error = Infallible; - - fn begin_frame(&mut self, width: u32, _height: u32) -> Result<(), Self::Error> { - self.width = width; - Ok(()) - } - - fn process(&mut self, row: BayerRow16<'_, BITS>) -> Result<(), Self::Error> { - let r = row.row(); - let w = self.width as usize; - let off = r * w * 3; - let dst = &mut self.out[off..off + 3 * w]; - bayer16_to_rgb_row::( - row.above(), - row.mid(), - row.below(), - row.row_parity(), - row.pattern(), - row.demosaic(), - row.m(), - dst, - false, - ); - Ok(()) - } - } - - impl BayerSink16 for CaptureRgbU8<'_, BITS> {} - - /// Sink that walks each `BayerRow16` through the public - /// u16-output dispatcher with SIMD off. - struct CaptureRgbU16<'a, const BITS: u32> { - out: &'a mut [u16], - width: u32, - } - - impl PixelSink for CaptureRgbU16<'_, BITS> { - type Input<'b> = BayerRow16<'b, BITS>; - type Error = Infallible; - - fn begin_frame(&mut self, width: u32, _height: u32) -> Result<(), Self::Error> { - self.width = width; - Ok(()) - } - - fn process(&mut self, row: BayerRow16<'_, BITS>) -> Result<(), Self::Error> { - let r = row.row(); - let w = self.width as usize; - let off = r * w * 3; - let dst = &mut self.out[off..off + 3 * w]; - bayer16_to_rgb_u16_row::( - row.above(), - row.mid(), - row.below(), - row.row_parity(), - row.pattern(), - row.demosaic(), - row.m(), - dst, - false, - ); - Ok(()) - } - } - - impl BayerSink16 for CaptureRgbU16<'_, BITS> {} - - /// Build a 12-bit low-packed RGGB Bayer plane from per-channel - /// nominal values (each 0..=4095). Bayer16 is low-packed: samples - /// occupy the low 12 bits of each `u16`, no shift required. - fn solid_rggb_12bit(width: u32, height: u32, r: u16, g: u16, b: u16) -> std::vec::Vec { - let w = width as usize; - let h = height as usize; - let mut data = std::vec![0u16; w * h]; - for y in 0..h { - for x in 0..w { - let v = match (y & 1, x & 1) { - (0, 0) => r, - (0, 1) => g, - (1, 0) => g, - (1, 1) => b, - _ => unreachable!(), - }; - data[y * w + x] = v; - } - } - data - } - - fn assert_full_frame_u8(rgb: &[u8], w: u32, h: u32, expect: (u8, u8, u8)) { - let w = w as usize; - let h = h as usize; - for y in 0..h { - for x in 0..w { - let i = (y * w + x) * 3; - assert_eq!(rgb[i], expect.0, "u8 px ({x},{y}) R"); - assert_eq!(rgb[i + 1], expect.1, "u8 px ({x},{y}) G"); - assert_eq!(rgb[i + 2], expect.2, "u8 px ({x},{y}) B"); - } - } - } - - fn assert_full_frame_u16(rgb: &[u16], w: u32, h: u32, expect: (u16, u16, u16)) { - let w = w as usize; - let h = h as usize; - for y in 0..h { - for x in 0..w { - let i = (y * w + x) * 3; - assert_eq!(rgb[i], expect.0, "u16 px ({x},{y}) R"); - assert_eq!(rgb[i + 1], expect.1, "u16 px ({x},{y}) G"); - assert_eq!(rgb[i + 2], expect.2, "u16 px ({x},{y}) B"); - } - } - } - - #[test] - fn bayer12_solid_red_rggb_yields_u8_red_full_frame() { - // 12-bit max = 4095. R = 4095 (white at this channel), G = B = 0. - // Mirror-by-2 boundary handling means every output pixel - // matches, including borders and corners. - let (w, h) = (8u32, 6u32); - let raw = solid_rggb_12bit(w, h, 4095, 0, 0); - let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgbU8::<12> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame_u8(&rgb, w, h, (255, 0, 0)); - } - - #[test] - fn bayer12_solid_red_rggb_yields_u16_red_full_frame() { - let (w, h) = (8u32, 6u32); - let raw = solid_rggb_12bit(w, h, 4095, 0, 0); - let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u16; (w * h * 3) as usize]; - let mut sink = CaptureRgbU16::<12> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_full_frame_u16(&rgb, w, h, (4095, 0, 0)); - } - - #[test] - fn bayer12_uniform_value_yields_uniform_u8_output() { - // Every sample = 2048 (low-packed 12-bit midgray). u8 output: - // 2048 / 4095 * 255 ≈ 127.53 → 128 everywhere (uniform input - // so edge clamping doesn't shift the value). - let (w, h) = (8u32, 6u32); - let raw = std::vec![2048u16; (w * h) as usize]; - let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgbU8::<12> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - for &c in &rgb { - assert!((c as i32 - 128).abs() <= 1, "got {c}"); - } - } - - #[test] - fn bayer12_uniform_value_yields_uniform_u16_output() { - // Every sample = 4095 (max 12-bit low-packed). u16 output - // should be 4095 (low-packed full white). - let (w, h) = (8u32, 6u32); - let raw = std::vec![4095u16; (w * h) as usize]; - let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u16; (w * h * 3) as usize]; - let mut sink = CaptureRgbU16::<12> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - for &c in &rgb { - assert_eq!(c, 4095); - } - } - - #[test] - fn bayer10_low_packed_white_yields_full_scale_u8() { - // 10-bit low-packed white (1023). u8 output should be 255. - let (w, h) = (8u32, 6u32); - let raw = std::vec![1023u16; (w * h) as usize]; - let frame = crate::frame::Bayer10Frame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgbU8::<10> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - for &c in &rgb { - assert_eq!(c, 255, "10-bit low-packed white must scale to u8 255"); - } - } - - #[test] - fn bayer14_low_packed_white_yields_full_scale_u16() { - // 14-bit low-packed white (16383). u16 output should be 16383. - let (w, h) = (8u32, 6u32); - let raw = std::vec![16383u16; (w * h) as usize]; - let frame = crate::frame::Bayer14Frame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u16; (w * h * 3) as usize]; - let mut sink = CaptureRgbU16::<14> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - for &c in &rgb { - assert_eq!(c, 16383, "14-bit low-packed white must stay 16383"); - } - } - - /// `Bayer12Frame::try_new` rejects out-of-range samples as - /// `BayerFrame16Error::SampleOutOfRange` — a recoverable - /// `Result::Err`, not a panic. Sample-range validation is now - /// part of standard frame construction so the walker is fully - /// fallible. - #[test] - fn bayer12_try_new_rejects_sample_above_max() { - let (w, h) = (4u32, 2u32); - let mut raw = std::vec![100u16; (w * h) as usize]; - raw[3] = 4096; // just above 12-bit max - let e = Bayer12Frame::try_new(&raw, w, h, w).unwrap_err(); - assert!(matches!( - e, - crate::frame::BayerFrame16Error::SampleOutOfRange { - index: 3, - value: 4096, - max_valid: 4095, - } - )); - } - - /// Codex-recommended regression: MSB-aligned 12-bit midgray - /// (e.g., `2048 << 4 = 0x8000`) is exactly the common - /// packing-mismatch bug, where a caller forgot to right-shift - /// before constructing the `Bayer12Frame`. Now caught at - /// construction as `Result::Err` instead of a runtime panic. - #[test] - fn bayer12_try_new_rejects_msb_aligned_input() { - let (w, h) = (4u32, 2u32); - let raw = std::vec![0x8000u16; (w * h) as usize]; // MSB-aligned 12-bit midgray - let e = Bayer12Frame::try_new(&raw, w, h, w).unwrap_err(); - assert!(matches!( - e, - crate::frame::BayerFrame16Error::SampleOutOfRange { - value: 0x8000, - max_valid: 4095, - .. - } - )); - } - - /// Codex-recommended partial-output regression: a Bayer12 frame - /// with a bad sample in a *later* row used to trigger a runtime - /// panic mid-walk; now `try_new` catches the bad sample upfront - /// and returns `Err`, so the user's output buffer is never - /// touched. (The `bayer16_to` walker can no longer be reached - /// with bad sample data because no `BayerFrame16` value - /// can exist with out-of-range samples.) - #[test] - fn bayer12_try_new_rejects_bad_sample_in_later_row() { - let (w, h) = (4u32, 8u32); - let mut raw = std::vec![100u16; (w * h) as usize]; - let off = (6 * w) as usize + 2; - raw[off] = 4096; // exceeds 12-bit max - let e = Bayer12Frame::try_new(&raw, w, h, w).unwrap_err(); - assert!(matches!( - e, - crate::frame::BayerFrame16Error::SampleOutOfRange { - value: 4096, - max_valid: 4095, - .. - } - )); - } - - /// Codex-recommended regression: a valid padded RAW buffer - /// (`stride > width`) with **stale high bits in the row - /// padding** must NOT trip the upfront pre-pass. The walker - /// only reads the active per-row region (`r * stride .. r * - /// stride + width`) so padding bytes are out of scope. - #[test] - fn bayer12_walker_accepts_padded_stride_with_dirty_padding() { - let w: u32 = 4; - let h: u32 = 4; - let stride: u32 = 8; // padding = 4 samples per row - let mut raw = std::vec![100u16; (stride * h) as usize]; - // Fill the padding with stale high bits (would trigger the - // upfront panic if validated). Active region (cols 0..4) is - // valid 12-bit data. - for r in 0..(h as usize) { - for c in 4..(stride as usize) { - raw[r * stride as usize + c] = 0xFFFF; - } - } - let frame = Bayer12Frame::try_new(&raw, w, h, stride).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgbU8::<12> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - // Sanity: kernel ran without panicking. Output content is - // not the focus; the test is the absence of panic. - } - - /// Companion regression: trailing backing storage past - /// `(h - 1) * stride + width` with junk must NOT trip the - /// pre-pass either — the walker doesn't read past the last - /// active row. - #[test] - fn bayer12_walker_accepts_overlong_slice_with_trailing_junk() { - let w: u32 = 4; - let h: u32 = 2; - let stride: u32 = 4; - // Backing storage is twice the declared geometry; trailing - // half is filled with values that would trip a wholesale - // scan. - let mut raw = std::vec![100u16; (stride * h * 2) as usize]; - for v in raw.iter_mut().skip((stride * h) as usize) { - *v = 0xFFFF; - } - let frame = Bayer12Frame::try_new(&raw, w, h, stride).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgbU8::<12> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - } - - /// At BITS=16 every `u16` is valid; the dispatcher's bad-bit - /// mask is zero so the check is a no-op and 0xFFFF passes. - #[test] - fn bayer16bit_dispatcher_accepts_full_u16_range() { - let (w, h) = (4u32, 2u32); - let raw = std::vec![0xFFFFu16; (w * h) as usize]; - let frame = crate::frame::Bayer16Frame::try_new(&raw, w, h, w).unwrap(); - let mut rgb = std::vec![0u8; (w * h * 3) as usize]; - let mut sink = CaptureRgbU8::<16> { - out: &mut rgb, - width: 0, - }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - // Solid 0xFFFF saturates to 255 on every channel. - for &c in &rgb { - assert_eq!(c, 255); - } - } - - #[test] - fn bayer12_walker_calls_sink_once_per_row() { - struct CountSink { - rows: u32, - } - impl PixelSink for CountSink { - type Input<'a> = BayerRow16<'a, BITS>; - type Error = Infallible; - fn process(&mut self, _row: BayerRow16<'_, BITS>) -> Result<(), Self::Error> { - self.rows += 1; - Ok(()) - } - } - impl BayerSink16 for CountSink {} - - let (w, h) = (8u32, 6u32); - let raw = std::vec![0u16; (w * h) as usize]; - let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); - let mut sink = CountSink::<12> { rows: 0 }; - bayer16_to( - &frame, - BayerPattern::Rggb, - BayerDemosaic::Bilinear, - WhiteBalance::neutral(), - ColorCorrectionMatrix::identity(), - &mut sink, - ) - .unwrap(); - assert_eq!(sink.rows, h); - } -} +mod tests; diff --git a/src/raw/bayer16/tests.rs b/src/raw/bayer16/tests.rs new file mode 100644 index 0000000..816075b --- /dev/null +++ b/src/raw/bayer16/tests.rs @@ -0,0 +1,474 @@ +use super::*; +use crate::{ + frame::Bayer12Frame, + row::{bayer16_to_rgb_row, bayer16_to_rgb_u16_row}, +}; +use core::convert::Infallible; + +/// Sink that walks each `BayerRow16` through the public +/// u8-output dispatcher with SIMD off. +struct CaptureRgbU8<'a, const BITS: u32> { + out: &'a mut [u8], + width: u32, +} + +impl PixelSink for CaptureRgbU8<'_, BITS> { + type Input<'b> = BayerRow16<'b, BITS>; + type Error = Infallible; + + fn begin_frame(&mut self, width: u32, _height: u32) -> Result<(), Self::Error> { + self.width = width; + Ok(()) + } + + fn process(&mut self, row: BayerRow16<'_, BITS>) -> Result<(), Self::Error> { + let r = row.row(); + let w = self.width as usize; + let off = r * w * 3; + let dst = &mut self.out[off..off + 3 * w]; + bayer16_to_rgb_row::( + row.above(), + row.mid(), + row.below(), + row.row_parity(), + row.pattern(), + row.demosaic(), + row.m(), + dst, + false, + ); + Ok(()) + } +} + +impl BayerSink16 for CaptureRgbU8<'_, BITS> {} + +/// Sink that walks each `BayerRow16` through the public +/// u16-output dispatcher with SIMD off. +struct CaptureRgbU16<'a, const BITS: u32> { + out: &'a mut [u16], + width: u32, +} + +impl PixelSink for CaptureRgbU16<'_, BITS> { + type Input<'b> = BayerRow16<'b, BITS>; + type Error = Infallible; + + fn begin_frame(&mut self, width: u32, _height: u32) -> Result<(), Self::Error> { + self.width = width; + Ok(()) + } + + fn process(&mut self, row: BayerRow16<'_, BITS>) -> Result<(), Self::Error> { + let r = row.row(); + let w = self.width as usize; + let off = r * w * 3; + let dst = &mut self.out[off..off + 3 * w]; + bayer16_to_rgb_u16_row::( + row.above(), + row.mid(), + row.below(), + row.row_parity(), + row.pattern(), + row.demosaic(), + row.m(), + dst, + false, + ); + Ok(()) + } +} + +impl BayerSink16 for CaptureRgbU16<'_, BITS> {} + +/// Build a 12-bit low-packed RGGB Bayer plane from per-channel +/// nominal values (each 0..=4095). Bayer16 is low-packed: samples +/// occupy the low 12 bits of each `u16`, no shift required. +fn solid_rggb_12bit(width: u32, height: u32, r: u16, g: u16, b: u16) -> std::vec::Vec { + let w = width as usize; + let h = height as usize; + let mut data = std::vec![0u16; w * h]; + for y in 0..h { + for x in 0..w { + let v = match (y & 1, x & 1) { + (0, 0) => r, + (0, 1) => g, + (1, 0) => g, + (1, 1) => b, + _ => unreachable!(), + }; + data[y * w + x] = v; + } + } + data +} + +fn assert_full_frame_u8(rgb: &[u8], w: u32, h: u32, expect: (u8, u8, u8)) { + let w = w as usize; + let h = h as usize; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) * 3; + assert_eq!(rgb[i], expect.0, "u8 px ({x},{y}) R"); + assert_eq!(rgb[i + 1], expect.1, "u8 px ({x},{y}) G"); + assert_eq!(rgb[i + 2], expect.2, "u8 px ({x},{y}) B"); + } + } +} + +fn assert_full_frame_u16(rgb: &[u16], w: u32, h: u32, expect: (u16, u16, u16)) { + let w = w as usize; + let h = h as usize; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) * 3; + assert_eq!(rgb[i], expect.0, "u16 px ({x},{y}) R"); + assert_eq!(rgb[i + 1], expect.1, "u16 px ({x},{y}) G"); + assert_eq!(rgb[i + 2], expect.2, "u16 px ({x},{y}) B"); + } + } +} + +#[test] +fn bayer12_solid_red_rggb_yields_u8_red_full_frame() { + // 12-bit max = 4095. R = 4095 (white at this channel), G = B = 0. + // Mirror-by-2 boundary handling means every output pixel + // matches, including borders and corners. + let (w, h) = (8u32, 6u32); + let raw = solid_rggb_12bit(w, h, 4095, 0, 0); + let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgbU8::<12> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame_u8(&rgb, w, h, (255, 0, 0)); +} + +#[test] +fn bayer12_solid_red_rggb_yields_u16_red_full_frame() { + let (w, h) = (8u32, 6u32); + let raw = solid_rggb_12bit(w, h, 4095, 0, 0); + let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u16; (w * h * 3) as usize]; + let mut sink = CaptureRgbU16::<12> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_full_frame_u16(&rgb, w, h, (4095, 0, 0)); +} + +#[test] +fn bayer12_uniform_value_yields_uniform_u8_output() { + // Every sample = 2048 (low-packed 12-bit midgray). u8 output: + // 2048 / 4095 * 255 ≈ 127.53 → 128 everywhere (uniform input + // so edge clamping doesn't shift the value). + let (w, h) = (8u32, 6u32); + let raw = std::vec![2048u16; (w * h) as usize]; + let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgbU8::<12> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + for &c in &rgb { + assert!((c as i32 - 128).abs() <= 1, "got {c}"); + } +} + +#[test] +fn bayer12_uniform_value_yields_uniform_u16_output() { + // Every sample = 4095 (max 12-bit low-packed). u16 output + // should be 4095 (low-packed full white). + let (w, h) = (8u32, 6u32); + let raw = std::vec![4095u16; (w * h) as usize]; + let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u16; (w * h * 3) as usize]; + let mut sink = CaptureRgbU16::<12> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + for &c in &rgb { + assert_eq!(c, 4095); + } +} + +#[test] +fn bayer10_low_packed_white_yields_full_scale_u8() { + // 10-bit low-packed white (1023). u8 output should be 255. + let (w, h) = (8u32, 6u32); + let raw = std::vec![1023u16; (w * h) as usize]; + let frame = crate::frame::Bayer10Frame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgbU8::<10> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + for &c in &rgb { + assert_eq!(c, 255, "10-bit low-packed white must scale to u8 255"); + } +} + +#[test] +fn bayer14_low_packed_white_yields_full_scale_u16() { + // 14-bit low-packed white (16383). u16 output should be 16383. + let (w, h) = (8u32, 6u32); + let raw = std::vec![16383u16; (w * h) as usize]; + let frame = crate::frame::Bayer14Frame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u16; (w * h * 3) as usize]; + let mut sink = CaptureRgbU16::<14> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + for &c in &rgb { + assert_eq!(c, 16383, "14-bit low-packed white must stay 16383"); + } +} + +/// `Bayer12Frame::try_new` rejects out-of-range samples as +/// `BayerFrame16Error::SampleOutOfRange` — a recoverable +/// `Result::Err`, not a panic. Sample-range validation is now +/// part of standard frame construction so the walker is fully +/// fallible. +#[test] +fn bayer12_try_new_rejects_sample_above_max() { + let (w, h) = (4u32, 2u32); + let mut raw = std::vec![100u16; (w * h) as usize]; + raw[3] = 4096; // just above 12-bit max + let e = Bayer12Frame::try_new(&raw, w, h, w).unwrap_err(); + assert!(matches!( + e, + crate::frame::BayerFrame16Error::SampleOutOfRange { + index: 3, + value: 4096, + max_valid: 4095, + } + )); +} + +/// Codex-recommended regression: MSB-aligned 12-bit midgray +/// (e.g., `2048 << 4 = 0x8000`) is exactly the common +/// packing-mismatch bug, where a caller forgot to right-shift +/// before constructing the `Bayer12Frame`. Now caught at +/// construction as `Result::Err` instead of a runtime panic. +#[test] +fn bayer12_try_new_rejects_msb_aligned_input() { + let (w, h) = (4u32, 2u32); + let raw = std::vec![0x8000u16; (w * h) as usize]; // MSB-aligned 12-bit midgray + let e = Bayer12Frame::try_new(&raw, w, h, w).unwrap_err(); + assert!(matches!( + e, + crate::frame::BayerFrame16Error::SampleOutOfRange { + value: 0x8000, + max_valid: 4095, + .. + } + )); +} + +/// Codex-recommended partial-output regression: a Bayer12 frame +/// with a bad sample in a *later* row used to trigger a runtime +/// panic mid-walk; now `try_new` catches the bad sample upfront +/// and returns `Err`, so the user's output buffer is never +/// touched. (The `bayer16_to` walker can no longer be reached +/// with bad sample data because no `BayerFrame16` value +/// can exist with out-of-range samples.) +#[test] +fn bayer12_try_new_rejects_bad_sample_in_later_row() { + let (w, h) = (4u32, 8u32); + let mut raw = std::vec![100u16; (w * h) as usize]; + let off = (6 * w) as usize + 2; + raw[off] = 4096; // exceeds 12-bit max + let e = Bayer12Frame::try_new(&raw, w, h, w).unwrap_err(); + assert!(matches!( + e, + crate::frame::BayerFrame16Error::SampleOutOfRange { + value: 4096, + max_valid: 4095, + .. + } + )); +} + +/// Codex-recommended regression: a valid padded RAW buffer +/// (`stride > width`) with **stale high bits in the row +/// padding** must NOT trip the upfront pre-pass. The walker +/// only reads the active per-row region (`r * stride .. r * +/// stride + width`) so padding bytes are out of scope. +#[test] +fn bayer12_walker_accepts_padded_stride_with_dirty_padding() { + let w: u32 = 4; + let h: u32 = 4; + let stride: u32 = 8; // padding = 4 samples per row + let mut raw = std::vec![100u16; (stride * h) as usize]; + // Fill the padding with stale high bits (would trigger the + // upfront panic if validated). Active region (cols 0..4) is + // valid 12-bit data. + for r in 0..(h as usize) { + for c in 4..(stride as usize) { + raw[r * stride as usize + c] = 0xFFFF; + } + } + let frame = Bayer12Frame::try_new(&raw, w, h, stride).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgbU8::<12> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + // Sanity: kernel ran without panicking. Output content is + // not the focus; the test is the absence of panic. +} + +/// Companion regression: trailing backing storage past +/// `(h - 1) * stride + width` with junk must NOT trip the +/// pre-pass either — the walker doesn't read past the last +/// active row. +#[test] +fn bayer12_walker_accepts_overlong_slice_with_trailing_junk() { + let w: u32 = 4; + let h: u32 = 2; + let stride: u32 = 4; + // Backing storage is twice the declared geometry; trailing + // half is filled with values that would trip a wholesale + // scan. + let mut raw = std::vec![100u16; (stride * h * 2) as usize]; + for v in raw.iter_mut().skip((stride * h) as usize) { + *v = 0xFFFF; + } + let frame = Bayer12Frame::try_new(&raw, w, h, stride).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgbU8::<12> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); +} + +/// At BITS=16 every `u16` is valid; the dispatcher's bad-bit +/// mask is zero so the check is a no-op and 0xFFFF passes. +#[test] +fn bayer16bit_dispatcher_accepts_full_u16_range() { + let (w, h) = (4u32, 2u32); + let raw = std::vec![0xFFFFu16; (w * h) as usize]; + let frame = crate::frame::Bayer16Frame::try_new(&raw, w, h, w).unwrap(); + let mut rgb = std::vec![0u8; (w * h * 3) as usize]; + let mut sink = CaptureRgbU8::<16> { + out: &mut rgb, + width: 0, + }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + // Solid 0xFFFF saturates to 255 on every channel. + for &c in &rgb { + assert_eq!(c, 255); + } +} + +#[test] +fn bayer12_walker_calls_sink_once_per_row() { + struct CountSink { + rows: u32, + } + impl PixelSink for CountSink { + type Input<'a> = BayerRow16<'a, BITS>; + type Error = Infallible; + fn process(&mut self, _row: BayerRow16<'_, BITS>) -> Result<(), Self::Error> { + self.rows += 1; + Ok(()) + } + } + impl BayerSink16 for CountSink {} + + let (w, h) = (8u32, 6u32); + let raw = std::vec![0u16; (w * h) as usize]; + let frame = Bayer12Frame::try_new(&raw, w, h, w).unwrap(); + let mut sink = CountSink::<12> { rows: 0 }; + bayer16_to( + &frame, + BayerPattern::Rggb, + BayerDemosaic::Bilinear, + WhiteBalance::neutral(), + ColorCorrectionMatrix::identity(), + &mut sink, + ) + .unwrap(); + assert_eq!(sink.rows, h); +} diff --git a/src/raw/types.rs b/src/raw/types.rs index e12340f..31b2d36 100644 --- a/src/raw/types.rs +++ b/src/raw/types.rs @@ -459,259 +459,4 @@ pub(crate) fn fuse_wb_ccm(wb: &WhiteBalance, ccm: &ColorCorrectionMatrix) -> [[f } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn white_balance_neutral_is_default() { - assert_eq!(WhiteBalance::default(), WhiteBalance::neutral()); - assert_eq!(WhiteBalance::neutral().r(), 1.0); - assert_eq!(WhiteBalance::neutral().g(), 1.0); - assert_eq!(WhiteBalance::neutral().b(), 1.0); - } - - #[test] - fn ccm_identity_is_default() { - assert_eq!( - ColorCorrectionMatrix::default(), - ColorCorrectionMatrix::identity() - ); - let id = ColorCorrectionMatrix::identity(); - let m = id.as_array(); - assert_eq!(m[0], [1.0, 0.0, 0.0]); - assert_eq!(m[1], [0.0, 1.0, 0.0]); - assert_eq!(m[2], [0.0, 0.0, 1.0]); - } - - #[test] - fn fuse_wb_ccm_with_neutral_wb_returns_ccm() { - let ccm = ColorCorrectionMatrix::new([[1.0, 0.5, 0.25], [0.0, 0.8, 0.2], [0.1, 0.1, 0.7]]); - let m = fuse_wb_ccm(&WhiteBalance::neutral(), &ccm); - assert_eq!(&m, ccm.as_array()); - } - - #[test] - fn fuse_wb_ccm_with_identity_ccm_returns_diag_wb() { - let wb = WhiteBalance::new(1.5, 1.0, 2.0); - let m = fuse_wb_ccm(&wb, &ColorCorrectionMatrix::identity()); - assert_eq!(m, [[1.5, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]]); - } - - #[test] - fn fuse_wb_ccm_scales_columns_by_wb() { - // M = CCM · diag(wb) ⇒ column j of M is column j of CCM × wb_j. - let ccm = - ColorCorrectionMatrix::new([[1.0, 2.0, 4.0], [8.0, 16.0, 32.0], [64.0, 128.0, 256.0]]); - let wb = WhiteBalance::new(0.5, 1.0, 0.25); - let m = fuse_wb_ccm(&wb, &ccm); - assert_eq!(m[0], [0.5, 2.0, 1.0]); - assert_eq!(m[1], [4.0, 16.0, 8.0]); - assert_eq!(m[2], [32.0, 128.0, 64.0]); - } - - // ---- WhiteBalance validation ------------------------------------------ - - #[test] - fn wb_try_new_rejects_nan() { - let e = WhiteBalance::try_new(f32::NAN, 1.0, 1.0).unwrap_err(); - assert!(matches!( - e, - WhiteBalanceError::NonFinite { - channel: WbChannel::R, - .. - } - )); - let e = WhiteBalance::try_new(1.0, f32::NAN, 1.0).unwrap_err(); - assert!(matches!( - e, - WhiteBalanceError::NonFinite { - channel: WbChannel::G, - .. - } - )); - let e = WhiteBalance::try_new(1.0, 1.0, f32::NAN).unwrap_err(); - assert!(matches!( - e, - WhiteBalanceError::NonFinite { - channel: WbChannel::B, - .. - } - )); - } - - #[test] - fn wb_try_new_rejects_infinity() { - let e = WhiteBalance::try_new(f32::INFINITY, 1.0, 1.0).unwrap_err(); - assert!(matches!(e, WhiteBalanceError::NonFinite { .. })); - let e = WhiteBalance::try_new(1.0, f32::NEG_INFINITY, 1.0).unwrap_err(); - assert!(matches!(e, WhiteBalanceError::NonFinite { .. })); - } - - #[test] - fn wb_try_new_rejects_negative() { - let e = WhiteBalance::try_new(-0.1, 1.0, 1.0).unwrap_err(); - assert!(matches!( - e, - WhiteBalanceError::Negative { - channel: WbChannel::R, - .. - } - )); - } - - #[test] - fn wb_try_new_accepts_zero_gain() { - // Zero gain zeroes the channel — degenerate but well-defined. - let wb = WhiteBalance::try_new(0.0, 1.0, 0.0).expect("zero gain valid"); - assert_eq!(wb.r(), 0.0); - } - - #[test] - fn wb_try_new_accepts_typical_gains() { - let wb = WhiteBalance::try_new(1.95, 1.0, 1.55).expect("typical"); - assert_eq!((wb.r(), wb.g(), wb.b()), (1.95, 1.0, 1.55)); - } - - #[test] - #[should_panic(expected = "invalid WhiteBalance")] - fn wb_new_panics_on_nan() { - let _ = WhiteBalance::new(f32::NAN, 1.0, 1.0); - } - - // ---- ColorCorrectionMatrix validation --------------------------------- - - #[test] - fn ccm_try_new_rejects_nan_off_diagonal() { - let e = - ColorCorrectionMatrix::try_new([[1.0, f32::NAN, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) - .unwrap_err(); - assert!(matches!( - e, - ColorCorrectionMatrixError::NonFinite { row: 0, col: 1, .. } - )); - } - - #[test] - fn ccm_try_new_rejects_infinity_diagonal() { - let e = - ColorCorrectionMatrix::try_new([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, f32::INFINITY]]) - .unwrap_err(); - assert!(matches!( - e, - ColorCorrectionMatrixError::NonFinite { row: 2, col: 2, .. } - )); - } - - #[test] - fn ccm_try_new_accepts_negative_off_diagonal() { - // Real CCMs subtract crosstalk → negative off-diagonal entries - // are normal. Only non-finite values should fail. - let ccm = - ColorCorrectionMatrix::try_new([[1.5, -0.3, -0.2], [-0.1, 1.2, -0.1], [-0.05, -0.15, 1.2]]) - .expect("negative entries valid"); - assert_eq!(ccm.as_array()[0][1], -0.3); - } - - #[test] - #[should_panic(expected = "invalid ColorCorrectionMatrix")] - fn ccm_new_panics_on_nan() { - let _ = ColorCorrectionMatrix::new([[f32::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); - } - - #[test] - fn fuse_wb_ccm_with_validated_inputs_is_finite() { - // Sanity: validated inputs always produce a finite fused matrix. - let wb = WhiteBalance::new(1.95, 1.0, 1.55); - let ccm = - ColorCorrectionMatrix::new([[1.5, -0.3, -0.2], [-0.1, 1.2, -0.1], [-0.05, -0.15, 1.2]]); - let m = fuse_wb_ccm(&wb, &ccm); - for row in m.iter() { - for &v in row.iter() { - assert!(v.is_finite(), "fused matrix has non-finite value: {v}"); - } - } - } - - // ---- WhiteBalance / ColorCorrectionMatrix magnitude bounds ------------- - - #[test] - fn wb_try_new_rejects_extreme_finite_gain() { - // A finite gain above the magnitude bound is rejected even - // though it would pass the NaN / Inf / negative checks. Real - // camera WB gains are O(1–10); 1e10 is well past the bound - // and would risk overflowing the per-pixel matmul. - let e = WhiteBalance::try_new(1e10, 1.0, 1.0).unwrap_err(); - assert!(matches!( - e, - WhiteBalanceError::OutOfBounds { - channel: WbChannel::R, - .. - } - )); - } - - #[test] - fn wb_try_new_accepts_value_at_bound() { - // Exactly at the bound is permitted; the bound itself doesn't - // overflow downstream arithmetic. - let wb = WhiteBalance::try_new(WhiteBalance::MAX_GAIN, 1.0, 1.0).expect("at-bound valid"); - assert_eq!(wb.r(), WhiteBalance::MAX_GAIN); - } - - #[test] - fn ccm_try_new_rejects_extreme_finite_coefficient() { - // Same principle for CCM elements — finite-but-extreme values - // that pass the is_finite check but would overflow per-pixel - // matmul are rejected via OutOfBounds. - let e = ColorCorrectionMatrix::try_new([[1.0, 0.0, 1e30], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) - .unwrap_err(); - assert!(matches!( - e, - ColorCorrectionMatrixError::OutOfBounds { row: 0, col: 2, .. } - )); - } - - #[test] - fn ccm_try_new_rejects_extreme_negative_coefficient() { - // Symmetric negative bound: real CCMs have negative - // off-diagonals, but only in the realistic ~[-5, 5] range. - let e = ColorCorrectionMatrix::try_new([[1.0, -1e10, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) - .unwrap_err(); - assert!(matches!( - e, - ColorCorrectionMatrixError::OutOfBounds { row: 0, col: 1, .. } - )); - } - - #[test] - fn ccm_try_new_accepts_typical_negative_off_diagonal() { - // Real-world CCM with crosstalk subtraction stays well within - // the bound and validates cleanly. - ColorCorrectionMatrix::try_new([[1.5, -0.3, -0.2], [-0.1, 1.2, -0.1], [-0.05, -0.15, 1.2]]) - .expect("typical CCM valid"); - } - - /// Codex regression: even at the bound, fusion + per-pixel - /// matmul stays finite for the maximum-stress 16-bit input. - /// `WB.MAX_GAIN * CCM.MAX_COEFFICIENT_ABS * 65535 ≈ 6.55e16`, - /// well under `f32::MAX ≈ 3.4e38`. - #[test] - fn fuse_wb_ccm_at_bounds_with_max_sample_stays_finite() { - let wb = WhiteBalance::try_new( - WhiteBalance::MAX_GAIN, - WhiteBalance::MAX_GAIN, - WhiteBalance::MAX_GAIN, - ) - .unwrap(); - let max = ColorCorrectionMatrix::MAX_COEFFICIENT_ABS; - let ccm = - ColorCorrectionMatrix::try_new([[max, max, max], [max, max, max], [max, max, max]]).unwrap(); - let m = fuse_wb_ccm(&wb, &ccm); - // Worst-case per-pixel sum: 3 channels * fused_max * 65535. - let sample = 65535.0f32; - for row in m.iter() { - let s = (row[0] + row[1] + row[2]) * sample; - assert!(s.is_finite(), "per-pixel sum overflowed at bound: {s}"); - } - } -} +mod tests; diff --git a/src/raw/types/tests.rs b/src/raw/types/tests.rs new file mode 100644 index 0000000..ea3b5bc --- /dev/null +++ b/src/raw/types/tests.rs @@ -0,0 +1,251 @@ +use super::*; + +#[test] +fn white_balance_neutral_is_default() { + assert_eq!(WhiteBalance::default(), WhiteBalance::neutral()); + assert_eq!(WhiteBalance::neutral().r(), 1.0); + assert_eq!(WhiteBalance::neutral().g(), 1.0); + assert_eq!(WhiteBalance::neutral().b(), 1.0); +} + +#[test] +fn ccm_identity_is_default() { + assert_eq!( + ColorCorrectionMatrix::default(), + ColorCorrectionMatrix::identity() + ); + let id = ColorCorrectionMatrix::identity(); + let m = id.as_array(); + assert_eq!(m[0], [1.0, 0.0, 0.0]); + assert_eq!(m[1], [0.0, 1.0, 0.0]); + assert_eq!(m[2], [0.0, 0.0, 1.0]); +} + +#[test] +fn fuse_wb_ccm_with_neutral_wb_returns_ccm() { + let ccm = ColorCorrectionMatrix::new([[1.0, 0.5, 0.25], [0.0, 0.8, 0.2], [0.1, 0.1, 0.7]]); + let m = fuse_wb_ccm(&WhiteBalance::neutral(), &ccm); + assert_eq!(&m, ccm.as_array()); +} + +#[test] +fn fuse_wb_ccm_with_identity_ccm_returns_diag_wb() { + let wb = WhiteBalance::new(1.5, 1.0, 2.0); + let m = fuse_wb_ccm(&wb, &ColorCorrectionMatrix::identity()); + assert_eq!(m, [[1.5, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]]); +} + +#[test] +fn fuse_wb_ccm_scales_columns_by_wb() { + // M = CCM · diag(wb) ⇒ column j of M is column j of CCM × wb_j. + let ccm = ColorCorrectionMatrix::new([[1.0, 2.0, 4.0], [8.0, 16.0, 32.0], [64.0, 128.0, 256.0]]); + let wb = WhiteBalance::new(0.5, 1.0, 0.25); + let m = fuse_wb_ccm(&wb, &ccm); + assert_eq!(m[0], [0.5, 2.0, 1.0]); + assert_eq!(m[1], [4.0, 16.0, 8.0]); + assert_eq!(m[2], [32.0, 128.0, 64.0]); +} + +// ---- WhiteBalance validation ------------------------------------------ + +#[test] +fn wb_try_new_rejects_nan() { + let e = WhiteBalance::try_new(f32::NAN, 1.0, 1.0).unwrap_err(); + assert!(matches!( + e, + WhiteBalanceError::NonFinite { + channel: WbChannel::R, + .. + } + )); + let e = WhiteBalance::try_new(1.0, f32::NAN, 1.0).unwrap_err(); + assert!(matches!( + e, + WhiteBalanceError::NonFinite { + channel: WbChannel::G, + .. + } + )); + let e = WhiteBalance::try_new(1.0, 1.0, f32::NAN).unwrap_err(); + assert!(matches!( + e, + WhiteBalanceError::NonFinite { + channel: WbChannel::B, + .. + } + )); +} + +#[test] +fn wb_try_new_rejects_infinity() { + let e = WhiteBalance::try_new(f32::INFINITY, 1.0, 1.0).unwrap_err(); + assert!(matches!(e, WhiteBalanceError::NonFinite { .. })); + let e = WhiteBalance::try_new(1.0, f32::NEG_INFINITY, 1.0).unwrap_err(); + assert!(matches!(e, WhiteBalanceError::NonFinite { .. })); +} + +#[test] +fn wb_try_new_rejects_negative() { + let e = WhiteBalance::try_new(-0.1, 1.0, 1.0).unwrap_err(); + assert!(matches!( + e, + WhiteBalanceError::Negative { + channel: WbChannel::R, + .. + } + )); +} + +#[test] +fn wb_try_new_accepts_zero_gain() { + // Zero gain zeroes the channel — degenerate but well-defined. + let wb = WhiteBalance::try_new(0.0, 1.0, 0.0).expect("zero gain valid"); + assert_eq!(wb.r(), 0.0); +} + +#[test] +fn wb_try_new_accepts_typical_gains() { + let wb = WhiteBalance::try_new(1.95, 1.0, 1.55).expect("typical"); + assert_eq!((wb.r(), wb.g(), wb.b()), (1.95, 1.0, 1.55)); +} + +#[test] +#[should_panic(expected = "invalid WhiteBalance")] +fn wb_new_panics_on_nan() { + let _ = WhiteBalance::new(f32::NAN, 1.0, 1.0); +} + +// ---- ColorCorrectionMatrix validation --------------------------------- + +#[test] +fn ccm_try_new_rejects_nan_off_diagonal() { + let e = ColorCorrectionMatrix::try_new([[1.0, f32::NAN, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + .unwrap_err(); + assert!(matches!( + e, + ColorCorrectionMatrixError::NonFinite { row: 0, col: 1, .. } + )); +} + +#[test] +fn ccm_try_new_rejects_infinity_diagonal() { + let e = + ColorCorrectionMatrix::try_new([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, f32::INFINITY]]) + .unwrap_err(); + assert!(matches!( + e, + ColorCorrectionMatrixError::NonFinite { row: 2, col: 2, .. } + )); +} + +#[test] +fn ccm_try_new_accepts_negative_off_diagonal() { + // Real CCMs subtract crosstalk → negative off-diagonal entries + // are normal. Only non-finite values should fail. + let ccm = + ColorCorrectionMatrix::try_new([[1.5, -0.3, -0.2], [-0.1, 1.2, -0.1], [-0.05, -0.15, 1.2]]) + .expect("negative entries valid"); + assert_eq!(ccm.as_array()[0][1], -0.3); +} + +#[test] +#[should_panic(expected = "invalid ColorCorrectionMatrix")] +fn ccm_new_panics_on_nan() { + let _ = ColorCorrectionMatrix::new([[f32::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); +} + +#[test] +fn fuse_wb_ccm_with_validated_inputs_is_finite() { + // Sanity: validated inputs always produce a finite fused matrix. + let wb = WhiteBalance::new(1.95, 1.0, 1.55); + let ccm = ColorCorrectionMatrix::new([[1.5, -0.3, -0.2], [-0.1, 1.2, -0.1], [-0.05, -0.15, 1.2]]); + let m = fuse_wb_ccm(&wb, &ccm); + for row in m.iter() { + for &v in row.iter() { + assert!(v.is_finite(), "fused matrix has non-finite value: {v}"); + } + } +} + +// ---- WhiteBalance / ColorCorrectionMatrix magnitude bounds ------------- + +#[test] +fn wb_try_new_rejects_extreme_finite_gain() { + // A finite gain above the magnitude bound is rejected even + // though it would pass the NaN / Inf / negative checks. Real + // camera WB gains are O(1–10); 1e10 is well past the bound + // and would risk overflowing the per-pixel matmul. + let e = WhiteBalance::try_new(1e10, 1.0, 1.0).unwrap_err(); + assert!(matches!( + e, + WhiteBalanceError::OutOfBounds { + channel: WbChannel::R, + .. + } + )); +} + +#[test] +fn wb_try_new_accepts_value_at_bound() { + // Exactly at the bound is permitted; the bound itself doesn't + // overflow downstream arithmetic. + let wb = WhiteBalance::try_new(WhiteBalance::MAX_GAIN, 1.0, 1.0).expect("at-bound valid"); + assert_eq!(wb.r(), WhiteBalance::MAX_GAIN); +} + +#[test] +fn ccm_try_new_rejects_extreme_finite_coefficient() { + // Same principle for CCM elements — finite-but-extreme values + // that pass the is_finite check but would overflow per-pixel + // matmul are rejected via OutOfBounds. + let e = ColorCorrectionMatrix::try_new([[1.0, 0.0, 1e30], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + .unwrap_err(); + assert!(matches!( + e, + ColorCorrectionMatrixError::OutOfBounds { row: 0, col: 2, .. } + )); +} + +#[test] +fn ccm_try_new_rejects_extreme_negative_coefficient() { + // Symmetric negative bound: real CCMs have negative + // off-diagonals, but only in the realistic ~[-5, 5] range. + let e = ColorCorrectionMatrix::try_new([[1.0, -1e10, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + .unwrap_err(); + assert!(matches!( + e, + ColorCorrectionMatrixError::OutOfBounds { row: 0, col: 1, .. } + )); +} + +#[test] +fn ccm_try_new_accepts_typical_negative_off_diagonal() { + // Real-world CCM with crosstalk subtraction stays well within + // the bound and validates cleanly. + ColorCorrectionMatrix::try_new([[1.5, -0.3, -0.2], [-0.1, 1.2, -0.1], [-0.05, -0.15, 1.2]]) + .expect("typical CCM valid"); +} + +/// Codex regression: even at the bound, fusion + per-pixel +/// matmul stays finite for the maximum-stress 16-bit input. +/// `WB.MAX_GAIN * CCM.MAX_COEFFICIENT_ABS * 65535 ≈ 6.55e16`, +/// well under `f32::MAX ≈ 3.4e38`. +#[test] +fn fuse_wb_ccm_at_bounds_with_max_sample_stays_finite() { + let wb = WhiteBalance::try_new( + WhiteBalance::MAX_GAIN, + WhiteBalance::MAX_GAIN, + WhiteBalance::MAX_GAIN, + ) + .unwrap(); + let max = ColorCorrectionMatrix::MAX_COEFFICIENT_ABS; + let ccm = + ColorCorrectionMatrix::try_new([[max, max, max], [max, max, max], [max, max, max]]).unwrap(); + let m = fuse_wb_ccm(&wb, &ccm); + // Worst-case per-pixel sum: 3 channels * fused_max * 65535. + let sample = 65535.0f32; + for row in m.iter() { + let s = (row[0] + row[1] + row[2]) * sample; + assert!(s.is_finite(), "per-pixel sum overflowed at bound: {s}"); + } +}