//! TRACES: FR-RAW-2 //! A second decoder behind the trait, and the callers none the wiser. //! //! FR-RAW-2 says a decoder may be added for broader camera coverage without //! changing callers (D2: LibRaw, for bodies rawler lacks). These tests are //! that claim, made checkable: [`Stub`] reads a container no real decoder //! knows, and the catalog scan, the preview ladder and export are each handed //! it through the same `&dyn Decoder` the application passes rawler through. //! //! Every assertion is on something only the stub could have produced — a //! camera name, a preview size, a header budget, a turn — so a caller that //! reached past the trait to `dr_decode::metadata` or its siblings would get //! rawler's refusal of these bytes and fail here, rather than pass on a real //! file where the two decoders happen to agree. use std::sync::Mutex; use dr_decode::{ BaseCurve, CfaPattern, CropRect, DecodeError, Decoder, Metadata, Preview, PreviewLocation, PreviewSize, RawImage, }; use dr_types::Orientation; /// What every stub file starts with. Not a signature any real format uses. const MAGIC: &[u8] = b"STUB-RAW"; /// The stub's header budget: far smaller than `dr_decode::HEADER_BYTES`, so a /// caller that fetched the shipped decoder's budget instead is visible. const HEADER: u64 = 64; /// The size of the preview [`Decoder::preview`] cuts, before orientation. const PREVIEW: (u32, u32) = (24, 12); /// The size of the JPEG embedded for the remote ladder, before orientation. const EMBEDDED: (u32, u32) = (40, 20); /// The sensor [`Decoder::decode`] returns. const SENSOR: (u32, u32) = (32, 16); /// EXIF 6: one clockwise quarter turn, so every size above comes out swapped. fn turned() -> Orientation { Orientation::from_exif(6) } /// A decoder for the stub container, recording the length of every header it /// is handed so a test can see how much the caller fetched. #[derive(Debug, Default)] struct Stub { headers: Mutex>, } impl Stub { fn is_ours(bytes: &[u8]) -> bool { bytes.starts_with(MAGIC) } fn refuse() -> DecodeError { DecodeError::Unsupported("not a stub file".into()) } } impl Decoder for Stub { fn header_bytes(&self) -> u64 { HEADER } fn metadata(&self, bytes: &[u8]) -> Result { self.headers.lock().unwrap().push(bytes.len()); if !Self::is_ours(bytes) { return Err(Self::refuse()); } Ok(Metadata { make: Some("Stubco".into()), model: Some("Stubco One".into()), iso: Some(321), captured_at: Some(1_234_567_890), orientation: Some(turned()), ..Default::default() }) } fn orientation(&self, header: &[u8]) -> Option { Self::is_ours(header).then(turned) } fn locate_preview(&self, header: &[u8], file_len: u64) -> Option { if !Self::is_ours(header) { return None; } let word = |at: usize| u32::from_le_bytes(header[at..at + 4].try_into().unwrap()) as u64; let (start, len) = (word(8), word(12)); (start + len <= file_len).then_some(PreviewLocation { range: start..start + len, width: Some(EMBEDDED.0), height: Some(EMBEDDED.1), }) } fn preview(&self, bytes: &[u8], _size: PreviewSize) -> Result { if !Self::is_ours(bytes) { return Err(Self::refuse()); } Ok(Preview { width: PREVIEW.0, height: PREVIEW.1, rgba: vec![200; (PREVIEW.0 * PREVIEW.1 * 4) as usize], }) } fn decode(&self, bytes: &[u8]) -> Result { if !Self::is_ours(bytes) { return Err(Self::refuse()); } let (width, height) = SENSOR; Ok(RawImage { width, height, data: vec![2048; (width * height) as usize], cfa_pattern: CfaPattern::Rggb, black_level: [0; 4], white_level: 4095, wb_coeffs: [1.0; 4], color_matrix: None, base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, width, height, }, samples_per_pixel: 1, profile: None, make: "Stubco".into(), model: "Stubco One".into(), }) } } /// A stub file: the magic, where the embedded JPEG sits, padding to the /// header budget, then the JPEG — kept well past [`HEADER`] so a header read /// and a whole-file read differ in length. fn stub_file() -> Vec { let (w, h) = EMBEDDED; let jpeg = dr_thumbs::encode_rgba(w, h, &vec![90; (w * h * 4) as usize]).expect("encodes"); let mut file = MAGIC.to_vec(); file.extend_from_slice(&(HEADER as u32).to_le_bytes()); file.extend_from_slice(&(jpeg.len() as u32).to_le_bytes()); file.resize(HEADER as usize, 0); file.extend_from_slice(&jpeg); file } const NAME: &str = "IMG_0001.STB"; /// The stub file in a folder of its own, served by the folder backend — the /// same `RemoteBackend` the scan and the ladder use against a real library. /// `tag` keeps two tests running at once out of each other's folder. fn library(tag: &str) -> (std::path::PathBuf, dr_sync_folder::FolderBackend, u64) { let dir = std::env::temp_dir().join(format!("dr-decoder-seam-{tag}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = stub_file(); std::fs::write(dir.join(NAME), &file).unwrap(); let backend = dr_sync_folder::FolderBackend::new(&dir).expect("folder opens"); (dir, backend, file.len() as u64) } fn request(size: u64) -> crate::library::ThumbnailRequest { crate::library::ThumbnailRequest { row: 0, path: NAME.into(), file_id: Some(1), size, image_id: 7, thumb_size: dr_thumbs::ThumbSize::Grid, needs_metadata: true, full_resolution: false, } } fn swapped((w, h): (u32, u32)) -> (u32, u32) { (h, w) } /// TRACES: FR-RAW-2 | FR-CAT-5 /// The catalog scan dates an image through the decoder it is given, and /// fetches the header that decoder asked for rather than the shipped one's. #[test] fn the_catalog_scan_reads_headers_through_the_trait() { let (dir, backend, size) = library("scan"); let stub = Stub::default(); let mut found = Vec::new(); let rt = crate::net_runtime::build().unwrap(); let reached = rt.block_on(crate::library::read_metadata_only( &backend, &stub, &request(size), &mut found, )); assert!(reached); assert_eq!(found.len(), 1, "the stub's header was read"); assert_eq!(found[0].image_id, 7); assert_eq!(found[0].camera.as_deref(), Some("Stubco One")); assert_eq!(found[0].captured_at, Some(1_234_567_890)); assert_eq!(found[0].iso, Some(321)); assert_eq!( *stub.headers.lock().unwrap(), vec![HEADER as usize], "one header, of the size the decoder declared" ); let _ = std::fs::remove_dir_all(dir); } /// TRACES: FR-RAW-2 | FR-CULL-2 | FR-NC-3 /// Every rung of the preview ladder takes its preview and its orientation /// from the decoder it is given: the remote two-stage fetch, the import's /// thumbnail, and the viewer's fallback when there is no GPU. #[test] fn the_preview_ladder_cuts_previews_through_the_trait() { let (dir, backend, size) = library("ladder"); let stub = Stub::default(); let bytes = stub_file(); // Remote: header, locate, fetch the range, decode, turn. let rt = crate::net_runtime::build().unwrap(); let mut found = Vec::new(); let outcome = rt.block_on(crate::library::fetch_preview( &backend, &stub, &request(size), &mut found, )); match outcome { crate::library::PreviewOutcome::Ready(p) => { assert_eq!((p.width, p.height), swapped(EMBEDDED)) } crate::library::PreviewOutcome::Unavailable(r) | crate::library::PreviewOutcome::Offline(r) => panic!("no preview: {r}"), } assert_eq!(found.len(), 1, "the same header dated the image"); // An import's thumbnail, from bytes already in hand. let thumb = crate::import::make_thumbnail(&stub, &bytes).expect("a thumbnail"); assert_eq!((thumb.width, thumb.height), swapped(PREVIEW)); // The viewer with no GPU, which shows the embedded preview read-only. let loaded = crate::load_bytes(None, &stub, &bytes).expect("a preview to show"); assert!(loaded.session.is_none()); assert_eq!((loaded.width, loaded.height), swapped(PREVIEW)); assert_eq!(loaded.meta.model.as_deref(), Some("Stubco One")); let _ = std::fs::remove_dir_all(dir); } /// TRACES: FR-RAW-2 | FR-EXP-8 | FR-EXP-9 /// Export opens a photograph for its full-size render through the decoder it /// is given: the header it carries and the sensor it renders are the stub's. #[test] fn export_decodes_through_the_trait() { let Some(gpu) = crate::develop::test_support::headless() else { eprintln!("no GPU adapter; skipping"); return; }; let stub = Stub::default(); let (meta, session) = crate::export::open_for_export(&gpu, &stub, &stub_file()).expect("the stub opens"); assert_eq!(meta.make.as_deref(), Some("Stubco")); assert_eq!( session.source_metadata().and_then(|m| m.model.as_deref()), Some("Stubco One"), "the session remembers the header it was opened from" ); assert_eq!(session.source_size(), swapped(SENSOR)); }