Test that a stub decoder reaches the scan, the ladder and export
FR-RAW-2's "without changing callers" needs a test that would fail if a caller named the concrete decoder; passing a real RAW through rawler cannot tell the two apart, because both routes give the same answer. The decoder_seam tests hand a stub decoder, for a container no real decoder reads, to the catalog scan (read_metadata_only over a folder backend), the preview ladder (the remote two-stage fetch, an import's thumbnail and the viewer's no-GPU fallback) and export (open_for_export, skipped without an adapter). Each assertion is on something only the stub produces: its camera and date, a header fetched at its 64-byte budget rather than HEADER_BYTES, preview and sensor sizes turned by its orientation. Switching collect_metadata or make_thumbnail back to the free functions fails two of the three tests. The develop test_support module is widened to the crate so the export test shares the one headless GPU context the other tests use. The requirements note for FR-RAW-2 now records the trait as built and the second decoder as not.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
//! 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<Vec<usize>>,
|
||||
}
|
||||
|
||||
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<Metadata, DecodeError> {
|
||||
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<Orientation> {
|
||||
Self::is_ours(header).then(turned)
|
||||
}
|
||||
|
||||
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation> {
|
||||
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<Preview, DecodeError> {
|
||||
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<RawImage, DecodeError> {
|
||||
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<u8> {
|
||||
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));
|
||||
}
|
||||
@@ -48,7 +48,7 @@ pub(super) const SEGMENT_PROXY_EDGE: u32 = 1600;
|
||||
/// the code they exercise left these three needed in most of the resulting
|
||||
/// files, so they live here once instead of being copied.
|
||||
#[cfg(test)]
|
||||
pub(super) mod test_support {
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
use dr_gpu::GpuContext;
|
||||
|
||||
@@ -142,7 +142,7 @@ pub(super) mod test_support {
|
||||
/// `OnceLock` rather than `lazy_static`: the initialiser runs once however
|
||||
/// many threads arrive together, and the losers block until it is done —
|
||||
/// which is precisely the property that was missing.
|
||||
pub(super) fn headless() -> Option<GpuContext> {
|
||||
pub(crate) fn headless() -> Option<GpuContext> {
|
||||
static SHARED: std::sync::OnceLock<Option<GpuContext>> = std::sync::OnceLock::new();
|
||||
SHARED
|
||||
.get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok())
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
mod activity;
|
||||
mod bursts;
|
||||
mod collections_ui;
|
||||
#[cfg(test)]
|
||||
mod decoder_seam;
|
||||
mod derived_sync;
|
||||
mod develop;
|
||||
mod develop_ui;
|
||||
|
||||
Reference in New Issue
Block a user