A window of cells whose thumbnails were cached but whose dates were not sent a header read per cell, and offline each one was three attempts at a 15 s connect timeout: `is_transient` counts a network error as worth retrying, and `read_metadata_only` returned a bare bool that could not say why a read failed. So the grid sat on "reading N dates" for minutes against a server that was not there, and no banner went up, because nothing in that loop ever reported the connection. `read_metadata_only` now returns a `DateRead`: reached, failed, or offline. An offline error is returned on the first attempt rather than retried — a dead server answers the second exactly as the first — while a 423 lock is still retried, which is what the retry was for. The grid's worker stops on it and sends `Offline`, as its fetch loop already did, so the banner goes up and the bar stops. The sweep's lanes stop on it too, one timeout each rather than one per image.
279 lines
9.6 KiB
Rust
279 lines
9.6 KiB
Rust
//! 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::{
|
|
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,
|
|
crop: CropRect {
|
|
x: 0,
|
|
y: 0,
|
|
width,
|
|
height,
|
|
},
|
|
samples_per_pixel: 1,
|
|
profile: None,
|
|
profile_tables: None,
|
|
baseline_exposure: 0.0,
|
|
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!(matches!(reached, crate::library::DateRead::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));
|
|
}
|