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:
@@ -243,9 +243,15 @@ read metadata, and `import.rs` fetches exactly that range through `Storage::read
|
|||||||
calling `dr_decode::metadata`. The decoder states its requirement and the storage layer satisfies
|
calling `dr_decode::metadata`. The decoder states its requirement and the storage layer satisfies
|
||||||
it; a decoder holding its own `SourceRef` would have had to implement the range policy itself.
|
it; a decoder holding its own `SourceRef` would have had to implement the range policy itself.
|
||||||
|
|
||||||
What is genuinely not built is the trait. There is one decoder, reached through free functions, so
|
*Status (2026-09-24).* The trait is built: `dr_decode::Decoder`, over bytes — `header_bytes`,
|
||||||
"without changing callers" is a claim nothing yet tests. The clause stands as written and is
|
`metadata`, `orientation`, `locate_preview`, `preview` and `decode` — with `dr_decode::Rawler` as
|
||||||
outstanding work, not a satisfied one.
|
its one implementation, delegating to the free functions that were there before. The catalog scan,
|
||||||
|
the thumbnail ladder, import, the viewer, export, merge and repairs take a `&dyn Decoder`; only the
|
||||||
|
places that start a job name `dr_decode::default()`. "Without changing callers" is tested by
|
||||||
|
`dr-ui`'s `decoder_seam` tests, which hand a stub decoder for a container no real decoder reads to
|
||||||
|
the scan, the ladder and export, and fail if any of them reaches past the trait. Nothing in the
|
||||||
|
trait takes a path or a `SourceRef`. The second decoder itself (LibRaw, D2) is not built; S7 (#48)
|
||||||
|
is what would say when it is needed.
|
||||||
|
|
||||||
**FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern
|
**FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern
|
||||||
identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at
|
identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at
|
||||||
|
|||||||
@@ -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
|
/// the code they exercise left these three needed in most of the resulting
|
||||||
/// files, so they live here once instead of being copied.
|
/// files, so they live here once instead of being copied.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) mod test_support {
|
pub(crate) mod test_support {
|
||||||
use super::*;
|
use super::*;
|
||||||
use dr_gpu::GpuContext;
|
use dr_gpu::GpuContext;
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ pub(super) mod test_support {
|
|||||||
/// `OnceLock` rather than `lazy_static`: the initialiser runs once however
|
/// `OnceLock` rather than `lazy_static`: the initialiser runs once however
|
||||||
/// many threads arrive together, and the losers block until it is done —
|
/// many threads arrive together, and the losers block until it is done —
|
||||||
/// which is precisely the property that was missing.
|
/// 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();
|
static SHARED: std::sync::OnceLock<Option<GpuContext>> = std::sync::OnceLock::new();
|
||||||
SHARED
|
SHARED
|
||||||
.get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok())
|
.get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok())
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
mod activity;
|
mod activity;
|
||||||
mod bursts;
|
mod bursts;
|
||||||
mod collections_ui;
|
mod collections_ui;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod decoder_seam;
|
||||||
mod derived_sync;
|
mod derived_sync;
|
||||||
mod develop;
|
mod develop;
|
||||||
mod develop_ui;
|
mod develop_ui;
|
||||||
|
|||||||
Reference in New Issue
Block a user