diff --git a/Cargo.lock b/Cargo.lock index 5f2609a5b..43d5f119c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3081,6 +3081,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-raster", "lightcraft-sysmem", + "lightcraft-testkit", "lightcraft-ui-egui", "log", "muda", @@ -3144,6 +3145,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-heif", "lightcraft-raster", + "lightcraft-testkit", "moxcms 0.9.1", "png", "proptest", @@ -3239,6 +3241,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-testkit", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3254,6 +3257,7 @@ dependencies = [ name = "lightcraft-faces" version = "0.6.0" dependencies = [ + "lightcraft-testkit", "matrixmultiply", "ndarray", "proptest", @@ -3304,6 +3308,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-raw", "lightcraft-scenes", + "lightcraft-testkit", "log", "pollster 1.0.1", "wgpu", @@ -3424,6 +3429,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-meta", "lightcraft-raster", + "lightcraft-testkit", "lightcraft-tiff", "miniz_oxide 0.8.9", "proptest", @@ -3454,6 +3460,7 @@ dependencies = [ "candle-core", "candle-nn", "lightcraft-fetch", + "lightcraft-testkit", "log", "serde", "serde_json", @@ -3464,6 +3471,10 @@ dependencies = [ name = "lightcraft-sysmem" version = "0.6.0" +[[package]] +name = "lightcraft-testkit" +version = "0.6.0" + [[package]] name = "lightcraft-tiff" version = "0.6.0" @@ -3493,6 +3504,7 @@ dependencies = [ "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", + "lightcraft-testkit", "log", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 7163c757c..feaee1d54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } lightcraft-geocode = { path = "crates/geocode" } +lightcraft-testkit = { path = "crates/testkit" } # foundation thiserror = "2" diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index bbe495c7d..26122c3e6 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -48,3 +48,7 @@ muda = { workspace = true } objc2 = { workspace = true } objc2-app-kit = { workspace = true } objc2-foundation = { workspace = true } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 66e5a78d7..1f84ec477 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -278,11 +278,11 @@ mod tests { ..wgpu::InstanceDescriptor::new_without_display_handle() }); let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { - eprintln!("no GPU adapter: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; }; let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { - eprintln!("no GPU device: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU device"); return; }; install_error_handler(&device); diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 166fbf4dc..1ae016bc8 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"] avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # JPEG XL tests read encoded samples without our colour conversion jxl-oxide = { workspace = true } proptest = { workspace = true } diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs index d3b2ceba3..9a86bc5e2 100644 --- a/crates/codecs/tests/corpus.rs +++ b/crates/codecs/tests/corpus.rs @@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec { fn corpus_images_decode() { let dir = corpus_root().join("images"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let (mut ok, mut unsupported) = (0, 0); @@ -57,7 +57,7 @@ fn corpus_images_decode() { fn corpus_pngsuite() { let dir = corpus_root().join("pngsuite"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let mut n = 0; @@ -83,7 +83,7 @@ fn corpus_pngsuite() { fn corpus_raw_is_routed() { let dir = corpus_root().join("raw"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } for p in files(&dir) { @@ -103,7 +103,7 @@ fn system_icc_profiles() { use lightcraft_codecs::icc::{IccColorModel, IccKind, parse}; let dir = Path::new("/System/Library/ColorSync/Profiles"); if !dir.is_dir() { - eprintln!("skip: no system ICC profiles"); + lightcraft_testkit::skip!(System, "no system ICC profiles"); return; } let expect = [ diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 2229c42a9..203135867 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -51,6 +51,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # HDR JPEG XL tests read the file's colour encoding jxl-oxide = { workspace = true } # Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on). diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..758714a75 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1828,7 +1828,7 @@ mod tests { .join("raw"); for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let mut raw = lightcraft_raw::decode(&bytes).unwrap(); @@ -1929,13 +1929,13 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/cr3-canon-r100-raw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = match lightcraft_raw::decode(&bytes) { Ok(raw) => raw, Err(lightcraft_raw::RawError::Unsupported(why)) => { - eprintln!("skip CR3 sensor decoder: {why}"); + lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); return; } Err(error) => panic!("CR3 corpus failed to decode: {error}"), @@ -1964,7 +1964,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; } Err(e) => panic!("{}: {e}", path.display()), @@ -1995,7 +1995,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d7500-lossless14.nef"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2015,7 +2015,7 @@ mod tests { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -2036,7 +2036,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2055,7 +2055,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2213,7 +2213,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } Err(e) => panic!("{}: {e}", path.display()), diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..1eaec749d 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -494,7 +494,7 @@ mod tests { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); let f = root.join("raw/rw2-panasonic-gx80.rw2"); if !f.exists() { - eprintln!("skip: {} absent", f.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", f.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..4b91e5b78 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -2414,7 +2414,7 @@ mod tests { fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { use ab_glyph::Font; let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { - eprintln!("skipped: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); return; }; let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); @@ -2486,7 +2486,7 @@ mod tests { compare(composed, decomposed, crate::fonts::CRAFT_FONTS); } } else { - eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); } } @@ -2510,7 +2510,7 @@ mod tests { use ab_glyph::Font; let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); if fonts.len() < 2 { - eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); return; } for c in "日本語の文字".chars() { @@ -3090,7 +3090,7 @@ mod tests { #[test] fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } let mut s = crate::Session::with_demo(); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..2ab7c21f0 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1133,7 +1133,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); @@ -1484,7 +1484,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let (img, _) = load_bytes(&bytes, 1600).unwrap(); diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index f1c27c849..49532c471 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() { /// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass. fn demo_with_yunet() -> Option { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; let dir = temp("yunet"); diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index aabcb03c3..5aab8f9a4 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -34,7 +34,7 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) { /// committed) and says whether it did: the parts of a test that need to find faces run only then. pub(crate) fn install_yunet(s: &mut Session) -> bool { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping the face-finding part"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set, so the face-finding part is not checked"); return false; }; let r = s.execute("faces.models.install", &json!({"path": file.to_string_lossy(), "acknowledged": true})); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..9c2b40849 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -581,7 +581,7 @@ fn convert_raw_to_dng() { let r = s.execute("photo.convertToDng", &json!({})).unwrap(); assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1)); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let nef = dir.join("shot.nef"); @@ -843,7 +843,7 @@ fn dng_export_compression_choices() { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d5100-uncompressed.nef"); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs index 11ffec44e..34aae9591 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/preview_read.rs @@ -13,7 +13,7 @@ use std::time::Instant; #[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"] fn a_preview_reads_only_the_header_and_the_preview() { let Ok(path) = std::env::var("LC_PREVIEW_RAW") else { - eprintln!("skipped: set LC_PREVIEW_RAW="); + lightcraft_testkit::skip!(Input, "set LC_PREVIEW_RAW="); return; }; let file_len = std::fs::metadata(&path).expect("the file exists").len(); diff --git a/crates/faces/Cargo.toml b/crates/faces/Cargo.toml index 112967cee..78ea0b798 100644 --- a/crates/faces/Cargo.toml +++ b/crates/faces/Cargo.toml @@ -22,4 +22,6 @@ sha2 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index b77f318bf..56f5d6474 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -263,7 +263,7 @@ mod tests { fn detector() -> Option { let Some(bytes) = model() else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; Some(Detector::new(&bytes).unwrap()) diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc348ee88..5547c548d 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -29,5 +29,7 @@ default = [] denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } lightcraft-scenes = { workspace = true } lightcraft-raw = { workspace = true } diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 4f078ff93..5872bfaa9 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -907,7 +907,7 @@ mod tests { match runner(net, tile) { Ok(r) => Some(r), Err(e) => { - eprintln!("skipped: no GPU runner here ({e})"); + lightcraft_testkit::skip!(Gpu, "no GPU runner here ({e})"); None } } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a971..16a9263c1 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -19,7 +19,7 @@ const MAX_LSB: u8 = 3; fn gpu() -> bool { let ok = lightcraft_gpu::available(); if !ok { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); } ok } diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs index fe2ecad5e..05347b446 100644 --- a/crates/gpu/tests/fallback.rs +++ b/crates/gpu/tests/fallback.rs @@ -38,7 +38,7 @@ fn masked(s: &mut DevelopSettings, k: usize) { #[test] fn failed_gpu_renders_fall_back_with_a_reason() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } assert_eq!(lightcraft_gpu::unavailable_reason(), None); diff --git a/crates/gpu/tests/memory.rs b/crates/gpu/tests/memory.rs index 2ef958cb1..25784f001 100644 --- a/crates/gpu/tests/memory.rs +++ b/crates/gpu/tests/memory.rs @@ -9,7 +9,7 @@ use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache}; #[test] fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; } let limit = 64u64 << 20; diff --git a/crates/gpu/tests/shutdown.rs b/crates/gpu/tests/shutdown.rs index b827a0711..a8d5cbd23 100644 --- a/crates/gpu/tests/shutdown.rs +++ b/crates/gpu/tests/shutdown.rs @@ -21,7 +21,8 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() { let req = RenderRequest::fit(w, h); let has_gpu = lightcraft_gpu::available(); if !has_gpu { - eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason()); + let why = lightcraft_gpu::unavailable_reason().unwrap_or_default(); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({why}), so only the CPU side is checked"); } assert!(!lightcraft_gpu::shutting_down()); assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet"); diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..84f9fa91a 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -28,6 +28,8 @@ default = ["jxl"] jxl = ["dep:jxl-oxide"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } jpeg-encoder = { workspace = true } proptest = { workspace = true } zune-jpegxl = { workspace = true } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..cf5bc5290 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -25,7 +25,7 @@ const KNOWN_UNSUPPORTED: &[&str] = &[ fn corpus_raw_decodes() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut paths: Vec = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect(); @@ -159,7 +159,7 @@ fn corpus_cr2_cfa_patterns() { let mut seen = 0; for (name, want) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -189,7 +189,7 @@ fn corpus_cr2_as_shot_white_balance() { "cr2-canon-80d.cr2", ] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB")); @@ -206,7 +206,7 @@ fn corpus_nef_compressed_matches_uncompressed() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d5100-lossless.nef")), std::fs::read(dir.join("nef-nikon-d5100-uncompressed.nef"))) else { - eprintln!("skip: D5100 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D5100 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -231,7 +231,7 @@ fn corpus_nef_compressed_matches_uncompressed() { fn corpus_nef_high_efficiency_is_named() { let dir = corpus_root().join("raw"); let (Ok(he), Ok(lossless)) = (std::fs::read(dir.join("nef-nikon-zf-he.nef")), std::fs::read(dir.join("nef-nikon-zf-lossless.nef"))) else { - eprintln!("skip: Z f samples absent"); + lightcraft_testkit::skip!(Corpus, "Z f samples absent"); return; }; for result in [decode(&he).map(|_| ()), probe_info(&he).map(|_| ())] { @@ -248,7 +248,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef"))) else { - eprintln!("skip: D7500 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D7500 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -278,7 +278,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { fn corpus_dngs_carry_profile_looks() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut seen = 0; @@ -327,7 +327,7 @@ fn corpus_dngs_carry_profile_looks() { fn corpus_proraw_sky_matte() { let path = corpus_root().join("raw/dng-apple-iphone12pro-proraw.dng"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let t0 = Instant::now(); @@ -365,7 +365,7 @@ fn corpus_sony_pre2017_colour_metadata() { let mut seen = 0; for (name, black, [r, b], (cw, ch)) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -397,7 +397,7 @@ fn corpus_sony_a7cr_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -464,7 +464,7 @@ fn corpus_sony_a7cm2_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -509,7 +509,7 @@ fn corpus_sony_black_levels_sit_at_the_data_floor() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -567,7 +567,7 @@ fn corpus_sony_sr2_white_balance() { let mut seen = 0; for (name, [r, b]) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -585,7 +585,7 @@ fn corpus_sony_sr2_white_balance() { fn corpus_sony_a900_packed12() { let name = "arw-sony-a900-packed12.arw"; let Ok(bytes) = std::fs::read(corpus_root().join("raw").join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); return; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -607,7 +607,7 @@ fn corpus_sony_16x9_crops() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -727,7 +727,7 @@ fn corpus_panasonic_encodings() { let mut seen = 0; for (name, bits, black, (cw, ch), cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -777,7 +777,7 @@ fn corpus_orf_cfa_patterns() { let mut seen = 0; for (name, cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -812,7 +812,7 @@ fn corpus_samsung_srw() { let mut seen = 0; for (name, bits, active, crop, cfa, whole) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -941,7 +941,7 @@ fn corpus_sony_arw6_samples() { fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut checked = 0; @@ -1004,7 +1004,7 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { ] { let path = root.join(name); if !path.exists() { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } let bytes = std::fs::read(&path).unwrap(); @@ -1026,7 +1026,7 @@ fn corpus_kodak_dcs520c() { let name = "2573_Kodak - DCS520C - 12bit (3_2).TIF"; let candidates = [corpus_root().join("raw").join("kodak-dcs520c.tif"), Path::new("P:/raw-pixls/Kodak/DCS520C").join(name)]; let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else { - eprintln!("skip: DCS520C sample absent"); + lightcraft_testkit::skip!(Corpus, "DCS520C sample absent"); return; }; assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs)); diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index ebf669307..06f7150a8 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -22,7 +22,7 @@ fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize fn check_against(name: &str, hash: u64, sensor: (usize, usize), developed: Option<(usize, usize)>, reference: &str) { let path = corpus().join("raw").join(name); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -118,7 +118,7 @@ fn canon_m50_craw_crop_is_the_shot_aspect() { // which is (12, 12) inside the valid sensor area. let path = corpus().join("raw").join("cr3-canon-m50-craw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap(); diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs index 3ab735299..4d9112bfb 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/sparse_corpus.rs @@ -58,7 +58,7 @@ fn partial_reads_describe_raws_as_whole_files_do() { .filter(|p| p.is_file() && !p.extension().is_some_and(|e| e == "part" || e == "txt" || e == "md")) .collect(); if paths.is_empty() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } paths.sort(); diff --git a/crates/segment/Cargo.toml b/crates/segment/Cargo.toml index 11dff5c77..b4614a81d 100644 --- a/crates/segment/Cargo.toml +++ b/crates/segment/Cargo.toml @@ -23,3 +23,7 @@ lightcraft-fetch = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] candle-core = { workspace = true, features = ["metal"] } candle-nn = { workspace = true, features = ["metal"] } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/reference.rs index a73fcaa7c..315d36cd1 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/reference.rs @@ -38,7 +38,7 @@ fn mask_disagreement(a: &[f32], b: &[f32]) -> f32 { #[test] fn matches_reference() { let Some((dir, r)) = setup() else { - eprintln!("skipped: set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); + lightcraft_testkit::skip!(Model, "set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); return; }; let mut model = Sam3::load(&dir).unwrap(); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..cefa1ef5c --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lightcraft-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft test tooling (dev-dependency only): a test that can't run here records why, so `cargo xtask ci` reports it" +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..7a2322245 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,190 @@ +//! Test tooling shared by the workspace's tests. A dev-dependency only: `cargo xtask layers` +//! refuses it as a normal dependency. Std only, so every crate can use it at no build cost. +//! +//! **Skips.** libtest has no "skipped" status and hides the output of passing tests, so a test that +//! needs a GPU, a corpus file, the craft-fonts faces or a model, and returns early without them, +//! passes silently: "all steps passed" could mean the GPU tests never ran. Such a test calls +//! [`skip!`] before it returns: +//! +//! ```no_run +//! # let gpu_available = false; +//! if !gpu_available { +//! lightcraft_testkit::skip!(Gpu, "no GPU adapter"); +//! return; +//! } +//! ``` +//! +//! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file +//! (`cargo xtask ci` sets it), appends one JSON line there: +//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`. +//! `cargo xtask ci` lists the skips by need and reason in its summary and in +//! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records +//! the part it could not run the same way. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::Path; + +/// The environment variable naming the file skips are appended to (JSON lines). +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// What a skipped test needed and didn't find. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Need { + /// A usable GPU adapter (`lightcraft_gpu::available()`). + Gpu, + /// A file from the gitignored `corpus/` (`cargo xtask corpus --download`) or a private corpus. + Corpus, + /// Faces from craft-fonts, embedded only in builds with `CRAFT_FONTS_DIR`. + Fonts, + /// A model file named by an environment variable (SAM 3, YuNet, …). + Model, + /// An input file named by an environment variable (opt-in tests). + Input, + /// Something the operating system provides (system ICC profiles, …). + System, + /// Support this build doesn't have yet (a decoder that refuses a file variant, an optional + /// cargo feature that is off). + Feature, +} + +impl Need { + /// The name in the skip log and the CI summary. + pub fn as_str(self) -> &'static str { + match self { + Need::Gpu => "gpu", + Need::Corpus => "corpus", + Need::Fonts => "fonts", + Need::Model => "model", + Need::Input => "input", + Need::System => "system", + Need::Feature => "feature", + } + } +} + +/// Record that this test (or part of it) can't run here: `skip!(Gpu, "no GPU adapter ({why})")`. +/// The first argument names a [`Need`] variant; the rest is a `format!` message. Doesn't return +/// from the test: follow it with `return` (or `continue`) as the test needs. +#[macro_export] +macro_rules! skip { + ($need:ident, $($arg:tt)+) => { + $crate::record($crate::Need::$need, &::std::format!($($arg)+), ::std::file!(), ::std::line!()) + }; +} + +/// What [`skip!`] calls: print the note and append it to the skip log, if one is set. Never fails +/// the test: a log that can't be written loses the entry, not the run. +pub fn record(need: Need, reason: &str, file: &str, line: u32) { + eprintln!("skipped ({}): {reason}", need.as_str()); + let log = std::env::var_os(SKIP_LOG_ENV).filter(|p| !p.is_empty()); + if let Some(log) = log { + let thread = std::thread::current(); + let binary = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| binary_name(&s.to_string_lossy()).to_owned())); + let entry = entry(need, reason, thread.name().unwrap_or(""), binary.as_deref().unwrap_or(""), file, line); + append(Path::new(&log), &entry); + } +} + +/// Append one line in a single write, so lines from parallel tests don't interleave. +fn append(path: &Path, line: &str) { + let file = std::fs::OpenOptions::new().create(true).append(true).open(path); + if let Err(e) = file.and_then(|mut f| f.write_all(line.as_bytes())) { + eprintln!("{SKIP_LOG_ENV}: can't append to {}: {e}", path.display()); + } +} + +/// A test binary's name without the hash cargo appends (`equivalence-0123abcd` → `equivalence`). +fn binary_name(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((name, hash)) if !name.is_empty() && hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()) => name, + _ => stem, + } +} + +/// One skip-log line (JSON, newline-terminated). +fn entry(need: Need, reason: &str, test: &str, binary: &str, file: &str, line: u32) -> String { + let mut s = String::from("{"); + for (i, (key, value)) in [("need", need.as_str()), ("reason", reason), ("test", test), ("binary", binary)].into_iter().enumerate() { + if i > 0 { + s.push(','); + } + let _ = write!(s, "\"{key}\":\"{}\"", escape(value)); + } + let _ = writeln!(s, ",\"location\":\"{}:{line}\"}}", escape(&file.replace('\\', "/"))); + s +} + +/// A JSON string's contents. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if u32::from(c) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_entry_is_one_json_line_with_its_text_escaped() { + let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28); + assert!(line.ends_with('\n')); + assert_eq!(line.matches('\n').count(), 1, "{line}"); + assert_eq!( + line, + "{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\ + \"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n" + ); + } + + #[test] + fn binary_names_lose_cargos_hash_only() { + assert_eq!(binary_name("equivalence-0f3a9c2b1d4e5f60"), "equivalence"); + assert_eq!(binary_name("lightcraft_engine-0f3a9c2b1d4e5f60"), "lightcraft_engine"); + assert_eq!(binary_name("sparse_corpus"), "sparse_corpus"); + assert_eq!(binary_name("cr3-corpus"), "cr3-corpus"); + assert_eq!(binary_name("-0f3a9c2b1d4e5f60"), "-0f3a9c2b1d4e5f60"); + assert_eq!(binary_name("tool-cafe"), "tool-cafe"); + } + + #[test] + fn appended_lines_accumulate() { + let dir = std::env::temp_dir().join(format!("lightcraft-testkit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("skips.jsonl"); + let _ = std::fs::remove_file(&log); + let threads: Vec<_> = (0..8) + .map(|i| { + let log = log.clone(); + std::thread::spawn(move || append(&log, &entry(Need::Gpu, &format!("no GPU {i}"), "t", "b", "f.rs", i))) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let text = std::fs::read_to_string(&log).unwrap(); + assert_eq!(text.lines().count(), 8); + assert!(text.lines().all(|l| l.starts_with("{\"need\":\"gpu\"") && l.ends_with('}'))); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_missing_log_directory_does_not_fail_the_test() { + append(Path::new("/nonexistent-lightcraft-dir/skips.jsonl"), "{}\n"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 74bc80058..38322d834 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,8 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..c040492a3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -597,7 +597,7 @@ mod tests { lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); faces.dedup(); if faces.is_empty() { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } ctx.fonts_mut(|fonts| { @@ -1200,7 +1200,7 @@ mod tests { let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; for (language, sample) in samples { if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } set_language(language); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a87a196ae..30a9823d1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -415,7 +415,7 @@ mod tests { #[test] fn thai_face_is_a_fallback_in_every_ui_family_when_craft_fonts_carry_one() { let Some(thai) = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")) else { - eprintln!("no Thai face in craft-fonts: nothing to check"); + lightcraft_testkit::skip!(Fonts, "no Thai face in craft-fonts"); return; }; set_language(Locale::En);