diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 579175086..bf67b2495 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -904,17 +904,9 @@ fn search_text_finds_people_and_quoted_values() { /// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be /// said photo by photo. #[test] -fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() { +fn photo_ops_record_touched_ids_and_changed_aspects() { let mut c = Catalog::default(); - let photo = |c: &mut Catalog, name: &str| { - let id = c.alloc_photo_id(); - c.apply(Op::AddPhoto { - photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), - }) - .unwrap(); - id - }; - let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg")); + let (a, b) = (photo(&mut c, "a.jpg", "2026-01-01T10:00:00"), photo(&mut c, "b.jpg", "2026-01-01T10:00:00")); let start = c.revision; assert_eq!(c.touched_since(start), Some(vec![])); assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); @@ -940,28 +932,25 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(start), None); assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); - // a revision that is not this catalog's past - assert_eq!(c.touched_since(c.revision + 5), None); - // a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after - // the next op nothing of before it - let old = c.revision; +} + +#[test] +fn history_handles_loaded_revisions_failed_batches_and_retention() { + let mut c = Catalog::default(); + let a = photo(&mut c, "a.jpg", "2026-01-01T10:00:00"); + let album = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap(); + // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch + // with an op that changes more than photos is not + let before = c.revision; c.revision = 7 << 32; - assert_eq!(c.touched_since(old), None); + assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(c.revision), None); let loaded = c.revision; c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); assert_eq!(c.touched_since(loaded), Some(vec![a])); - assert_eq!(c.touched_since(old), None); - // an op that fails changes nothing and is not remembered - let before = c.revision; - assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); - assert_eq!(c.revision, before); - assert_eq!(c.touched_since(before), Some(vec![])); - c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); - assert_eq!(c.touched_since(before), Some(vec![a])); - assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); - // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch - // with an op that changes more than photos is not + assert_eq!(c.touched_since(before), None); + let before = c.revision; assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); diff --git a/crates/engine/src/crs/tests.rs b/crates/engine/src/crs/tests.rs index 2779194c1..9f46cf401 100644 --- a/crates/engine/src/crs/tests.rs +++ b/crates/engine/src/crs/tests.rs @@ -37,9 +37,16 @@ fn props(x: &str) -> Props { #[test] fn maps_common_fields() { let p = props(SIDECAR); - assert!(has_adjustments(&p)); let partial = to_partial(&p, Target::RawAbsolute); let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert!(has_adjustments(&p)); + assert_common_tone_fields(&s); + assert_common_color_fields(&s); + assert_common_detail_fields(&s); + assert_common_geometry_fields(&s); +} + +fn assert_common_tone_fields(s: &DevelopSettings) { assert_eq!(s.wb.mode, WbMode::Custom); assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); assert_eq!(s.light.exposure, 0.65); @@ -54,15 +61,24 @@ fn maps_common_fields() { assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); +} + +fn assert_common_color_fields(s: &DevelopSettings) { assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); +} + +fn assert_common_detail_fields(s: &DevelopSettings) { assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); assert_eq!(s.vignette.amount, -21.0); assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); +} + +fn assert_common_geometry_fields(s: &DevelopSettings) { assert_eq!(s.geometry.upright, Upright::Level); assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs index 80eb13430..139939a1d 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -1067,6 +1067,11 @@ fn avif_ten_bit() { /// unknown keys (naming the nearest known one) and values of the wrong kind or range. #[test] fn export_params_are_validated() { + assert_invalid_export_params(); + assert_export_options_round_trip(); +} + +fn assert_invalid_export_params() { use serde_json::json; let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); let e = err(json!({"longEdgee": 400})); @@ -1092,6 +1097,11 @@ fn export_params_are_validated() { .to_string() .starts_with("invalid parameters for `export.savePreset`") ); +} + +fn assert_export_options_round_trip() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); // everything the presets, the dialog, Export with Previous and `render --opt` send passes for p in builtin_presets() { ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); diff --git a/crates/engine/src/sidecar/tests_repair_companions.rs b/crates/engine/src/sidecar/tests_repair_companions.rs index 94c7996a7..05402f975 100644 --- a/crates/engine/src/sidecar/tests_repair_companions.rs +++ b/crates/engine/src/sidecar/tests_repair_companions.rs @@ -73,85 +73,85 @@ fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions for upper_full in [false, true] { for naming in [SidecarNaming::Stem, SidecarNaming::Full] { for auto in [false, true] { - let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); - let a = dir.join("a.jpg"); - let b = dir.join("Trip.jpg"); - std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); - let path = |photo: &Path, style, upper| { - let path = sidecar_path(&photo.to_string_lossy(), style); - if upper { path.with_extension("XMP") } else { path } - }; - let stem_a = path(&a, SidecarNaming::Stem, upper_stem); - let full_a = path(&a, SidecarNaming::Full, upper_full); - let stem_b = path(&b, SidecarNaming::Stem, upper_stem); - let full_b = path(&b, SidecarNaming::Full, upper_full); - let stem_bytes = packet("stem-keep"); - let full_bytes = packet("full-keep"); - std::fs::write(&stem_a, &stem_bytes).unwrap(); - std::fs::write(&full_a, &full_bytes).unwrap(); - let mut s = session(&a); - s.xmp.naming = naming; - s.xmp.auto_write = auto; - let fs = Faults(vec![full_a.clone(), b.clone()]); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queues(&s); - assert_eq!(pending.len(), 1); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - - // Missing packet and an unrelated packet at either case spelling remain protected. - let held = dir.join("held.xmp"); - std::fs::rename(&full_a, &held).unwrap(); - s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::rename(&held, &full_a).unwrap(); - for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { - std::fs::write(&collision, packet("unrelated-packet")).unwrap(); - let occupied = std::fs::read(&collision).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read(&collision).unwrap(), occupied); - assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::remove_file(collision).unwrap(); - } - // AutoWrite is the first successful repair when enabled; explicit save otherwise. - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - if !auto { - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); - } - assert!(queues(&s).is_empty()); - assert!(!full_a.exists()); - let (written, untouched, expected) = - if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; - assert_fresh(written); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); - for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { - s.execute(command, &json!({})).unwrap(); - if !auto { - save(&mut s); - } - assert_fresh(written); - assert_eq!( - parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, - Some(rating) - ); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - } - assert_eq!(s.xmp.naming, naming); - assert_eq!(s.xmp.auto_write, auto); - drop(s); - std::fs::remove_dir_all(dir).unwrap(); + check_companion_scenario(upper_stem, upper_full, naming, auto); } } } } } +fn check_companion_scenario(upper_stem: bool, upper_full: bool, naming: SidecarNaming, auto: bool) { + let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); + let path = |photo: &Path, style, upper| { + let path = sidecar_path(&photo.to_string_lossy(), style); + if upper { path.with_extension("XMP") } else { path } + }; + let stem_a = path(&a, SidecarNaming::Stem, upper_stem); + let full_a = path(&a, SidecarNaming::Full, upper_full); + let stem_b = path(&b, SidecarNaming::Stem, upper_stem); + let full_b = path(&b, SidecarNaming::Full, upper_full); + let stem_bytes = packet("stem-keep"); + let full_bytes = packet("full-keep"); + std::fs::write(&stem_a, &stem_bytes).unwrap(); + std::fs::write(&full_a, &full_bytes).unwrap(); + let mut s = session(&a); + s.xmp.naming = naming; + s.xmp.auto_write = auto; + let fs = Faults(vec![full_a.clone(), b.clone()]); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queues(&s); + assert_eq!(pending.len(), 1); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + + // Missing packet and an unrelated packet at either case spelling remain protected. + let held = dir.join("held.xmp"); + std::fs::rename(&full_a, &held).unwrap(); + s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::rename(&held, &full_a).unwrap(); + for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { + std::fs::write(&collision, packet("unrelated-packet")).unwrap(); + let occupied = std::fs::read(&collision).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read(&collision).unwrap(), occupied); + assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::remove_file(collision).unwrap(); + } + // AutoWrite is the first successful repair when enabled; explicit save otherwise. + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + if !auto { + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); + } + assert!(queues(&s).is_empty()); + assert!(!full_a.exists()); + let (written, untouched, expected) = + if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; + assert_fresh(written); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); + for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { + s.execute(command, &json!({})).unwrap(); + if !auto { + save(&mut s); + } + assert_fresh(written); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(rating)); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + } + assert_eq!(s.xmp.naming, naming); + assert_eq!(s.xmp.auto_write, auto); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); +} #[test] fn rollback_repair_and_shared_copy_keep_known_companions() { for rollback in [false, true] { diff --git a/crates/engine/src/sidecar/tests_repair_provenance.rs b/crates/engine/src/sidecar/tests_repair_provenance.rs index 86f7d1dd6..9c9b7c084 100644 --- a/crates/engine/src/sidecar/tests_repair_provenance.rs +++ b/crates/engine/src/sidecar/tests_repair_provenance.rs @@ -348,64 +348,12 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { // A private-method contract, not a UI reproduction: application reachability of two // simultaneous repair owners is unproven. Native move failures and follow_stuck seed // both records; the second owner's normal apply_rename preflight is deliberately bypassed. - struct NativePartial { - id: PhotoId, - original: PathBuf, - moved: PathBuf, - stem: PathBuf, - full: PathBuf, - companion: PathBuf, - target: PathBuf, - } let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract"); let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap(); let mut s = Session::new(); s.xmp.naming = SidecarNaming::Stem; - let mut native = Vec::new(); - for (i, name) in ["first", "second"].into_iter().enumerate() { - let dir = root.join(name); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("a.png"); - let moved = dir.join("Trip.png"); - std::fs::write(&original, &png).unwrap(); - let stem = path(&original, SidecarNaming::Stem, false); - let full = path(&original, SidecarNaming::Full, true); - for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { - std::fs::write(p, packet(&marker)).unwrap(); - assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); - } - let id = PhotoId(i as u64 + 1); - let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - let fs = Faults(vec![full.clone(), moved.clone()]); - if i == 0 { - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.apply_rename_with(&fs, &plans).is_err()); - } else { - // Register the genuine second partial move without draining the first repair. - let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; - let err = crate::rename::move_all(&fs, &moves).unwrap_err(); - assert_eq!(err.stuck, moves); - assert_eq!(err.pending_sidecars.len(), 1); - let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; - s.follow_stuck(&op, err, true); - } - assert_eq!(s.undo.len(), i + 1); - assert_eq!(queued(&s).len(), i + 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); - let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); - assert_eq!((decoded.width, decoded.height), (4, 3)); - native.push(NativePartial { - id, - original, - companion: path(&moved, SidecarNaming::Stem, false), - target: path(&moved, SidecarNaming::Full, true), - moved, - stem, - full, - }); - } + let native = create_native_partials(&root, &png, &mut s); let first = &native[0]; let second = &native[1]; let first_stem = std::fs::read(&first.companion).unwrap(); @@ -463,3 +411,61 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { drop(s); std::fs::remove_dir_all(root).unwrap(); } + +struct NativePartial { + id: PhotoId, + original: PathBuf, + moved: PathBuf, + stem: PathBuf, + full: PathBuf, + companion: PathBuf, + target: PathBuf, +} + +fn create_native_partials(root: &Path, png: &[u8], s: &mut Session) -> Vec { + let mut native = Vec::new(); + for (i, name) in ["first", "second"].into_iter().enumerate() { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("a.png"); + let moved = dir.join("Trip.png"); + std::fs::write(&original, png).unwrap(); + let stem = path(&original, SidecarNaming::Stem, false); + let full = path(&original, SidecarNaming::Full, true); + for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { + std::fs::write(p, packet(&marker)).unwrap(); + assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); + } + let id = PhotoId(i as u64 + 1); + let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let fs = Faults(vec![full.clone(), moved.clone()]); + if i == 0 { + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.apply_rename_with(&fs, &plans).is_err()); + } else { + // Register the genuine second partial move without draining the first repair. + let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; + let err = crate::rename::move_all(&fs, &moves).unwrap_err(); + assert_eq!(err.stuck, moves); + assert_eq!(err.pending_sidecars.len(), 1); + let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; + s.follow_stuck(&op, err, true); + } + assert_eq!(s.undo.len(), i + 1); + assert_eq!(queued(s).len(), i + 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); + let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); + assert_eq!((decoded.width, decoded.height), (4, 3)); + native.push(NativePartial { + id, + original, + companion: path(&moved, SidecarNaming::Stem, false), + target: path(&moved, SidecarNaming::Full, true), + moved, + stem, + full, + }); + } + native +} diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs index 4b622e657..6fd50aea6 100644 --- a/crates/engine/src/sidecar/tests_repair_saves.rs +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -58,118 +58,122 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] { for upper in [false, true] { for switch in [false, true] { - let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); - let a = root.join("a.png"); - let b = root.join("Trip.png"); - png(&a); - let mut old = sidecar_path(&a.to_string_lossy(), naming); - if upper { - old = old.with_extension("XMP"); - } - std::fs::write(&old, FOREIGN).unwrap(); - let mut s = Session::new().with_fs(); - s.open_library(root.join("library-a"), false).unwrap(); - s.execute("library.import", &json!({"paths": [a]})).unwrap(); - let mut id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - s.undo.clear(); - let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(queued(&s).len(), 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queued(&s); - let held = root.join("temporarily-unavailable.xmp"); - std::fs::rename(&old, &held).unwrap(); - if switch { - s.close_library().unwrap(); - s.open_library(root.join("library-b"), false).unwrap(); - assert_eq!(s.pending_sidecar_repairs, pending); - s.execute("library.import", &json!({"paths": [b]})).unwrap(); - id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - } - s.xmp.naming = save_naming; - s.xmp.auto_write = true; - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - let preferred = sidecar_path(&b.to_string_lossy(), save_naming); - assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); - assert!(!pending[0].to.exists()); - assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); - let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); - assert!(saved["written"].as_array().unwrap().is_empty()); - assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); - assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); - assert_eq!(queued(&s), pending); - - // A missing sidecar for another photo does not prevent this healthy save. - let healthy = root.join("healthy.png"); - png(&healthy); - let healthy_id = PhotoId(999); - s.commit( - "Healthy Photo", - Op::AddPhoto { - photo: Box::new(Photo::new( - healthy_id, - Source::File { path: healthy.to_string_lossy().into_owned() }, - "healthy.png", - "PNG", - 4, - 3, - "", - )), - }, - ) - .unwrap(); - s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); - let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); - assert_eq!(queued(&s), pending); - - // An existing destination is never overwritten and the original packet remains. - std::fs::rename(&held, &old).unwrap(); - let destination = pending[0].to.clone(); - let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); - let collision_path = if upper { preferred.clone() } else { destination.clone() }; - std::fs::write(&collision_path, &collision).unwrap(); - s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); - assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - assert_eq!(queued(&s), pending); - s.selection = crate::Selection::single(id); - assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision - - // The next successful AutoWrite runs the real repair and production XMP merge. - s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); - assert!(queued(&s).is_empty()); - assert!(!old.exists()); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); - assert_eq!(parsed.rating, Some(4)); - assert_eq!(parsed.title.as_deref(), Some("Final title")); - assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); - if upper { - // by listing: on a case-insensitive volume `preferred` answers for the upper-case packet - let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); - assert!(!listed, "merge uses the repaired uppercase packet"); - } - for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { - s.execute(step, &json!({})).unwrap(); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); - } - drop(s); - std::fs::remove_dir_all(root).unwrap(); + check_autowrite_repair_scenario(naming, save_naming, upper, switch); } } } } } +fn check_autowrite_repair_scenario(naming: SidecarNaming, save_naming: SidecarNaming, upper: bool, switch: bool) { + let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); + let a = root.join("a.png"); + let b = root.join("Trip.png"); + png(&a); + let mut old = sidecar_path(&a.to_string_lossy(), naming); + if upper { + old = old.with_extension("XMP"); + } + std::fs::write(&old, FOREIGN).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(root.join("library-a"), false).unwrap(); + s.execute("library.import", &json!({"paths": [a]})).unwrap(); + let mut id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + s.undo.clear(); + let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(queued(&s).len(), 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queued(&s); + let held = root.join("temporarily-unavailable.xmp"); + std::fs::rename(&old, &held).unwrap(); + if switch { + s.close_library().unwrap(); + s.open_library(root.join("library-b"), false).unwrap(); + assert_eq!(s.pending_sidecar_repairs, pending); + s.execute("library.import", &json!({"paths": [b]})).unwrap(); + id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + } + s.xmp.naming = save_naming; + s.xmp.auto_write = true; + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + let preferred = sidecar_path(&b.to_string_lossy(), save_naming); + assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); + assert!(!pending[0].to.exists()); + assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); + let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); + assert!(saved["written"].as_array().unwrap().is_empty()); + assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); + assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); + assert_eq!(queued(&s), pending); + + // A missing sidecar for another photo does not prevent this healthy save. + let healthy = root.join("healthy.png"); + png(&healthy); + let healthy_id = PhotoId(999); + s.commit( + "Healthy Photo", + Op::AddPhoto { + photo: Box::new(Photo::new(healthy_id, Source::File { path: healthy.to_string_lossy().into_owned() }, "healthy.png", "PNG", 4, 3, "")), + }, + ) + .unwrap(); + s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); + let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); + assert_eq!(queued(&s), pending); + + // An existing destination is never overwritten and the original packet remains. + std::fs::rename(&held, &old).unwrap(); + let destination = pending[0].to.clone(); + let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); + let collision_path = if upper { preferred.clone() } else { destination.clone() }; + std::fs::write(&collision_path, &collision).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + assert_eq!(queued(&s), pending); + s.selection = crate::Selection::single(id); + assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision + + // The next successful AutoWrite runs the real repair and production XMP merge. + s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); + assert!(queued(&s).is_empty()); + assert!(!old.exists()); + let packet = std::fs::read_to_string(&destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); + assert_eq!(parsed.rating, Some(4)); + assert_eq!(parsed.title.as_deref(), Some("Final title")); + assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); + assert_repaired_case_spelling(&root, &preferred, upper); + assert_undo_redo_preserve_foreign_packet(&mut s, &destination); + drop(s); + std::fs::remove_dir_all(root).unwrap(); +} + +fn assert_repaired_case_spelling(root: &Path, preferred: &Path, upper: bool) { + if !upper { + return; + } + // By listing, a case-insensitive volume answers for the uppercase packet. + let listed = std::fs::read_dir(root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); + assert!(!listed, "merge uses the repaired uppercase packet"); +} + +fn assert_undo_redo_preserve_foreign_packet(s: &mut Session, destination: &Path) { + for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { + s.execute(step, &json!({})).unwrap(); + let packet = std::fs::read_to_string(destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); + } +} #[test] fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() { let root = crate::tests_xmp::temp_dir("repair-save-shared-stem"); diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs index 6e5585a82..dc1c6c8f2 100644 --- a/crates/engine/src/tests/import.rs +++ b/crates/engine/src/tests/import.rs @@ -1,7 +1,11 @@ //! Import: recursive folders, duplicate detection by content, add vs copy into the library. -use std::path::Path; +use std::{ + path::{Path, PathBuf}, + sync::atomic::AtomicUsize, +}; +use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; use crate::Session; @@ -1711,109 +1715,139 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { } fn smart_look_refresh_for(format: &str) { - use std::sync::atomic::{AtomicUsize, Ordering}; - let src = temp_dir(&format!("smartlook-src-{format}")); - let lib = temp_dir(&format!("smartlook-lib-{format}")); - write_png(&src.join("a.png"), 3); - write_png(&src.join("b.png"), 9); - let mut s = Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); - let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; - let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); - // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) - let mut raw = (**s.catalog.photo(id).unwrap()).clone(); - raw.format = format.into(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); - assert!(!s.catalog.photo(png).unwrap().relative_wb()); - s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); - let dir = s.media.smart_dir.clone().unwrap(); - let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); - let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap()); - let (old, new) = (test_tone(1.5), test_tone(3.5)); - let calls = std::sync::Arc::new(AtomicUsize::new(0)); - let plain = s.media.file_loader.clone().unwrap(); - s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let mut case = SmartLookCase::new(format); + assert_current_smart_look_is_not_rebuilt(&mut case); + let (old_bytes, png_bytes) = keep_offline_previews(&mut case); + refresh_online_previews(&mut case, &old_bytes, &png_bytes); + let _ = std::fs::remove_dir_all(&case.src); + let _ = std::fs::remove_dir_all(&case.lib); +} + +struct SmartLookCase { + src: PathBuf, + lib: PathBuf, + session: Session, + id: PhotoId, + dir: PathBuf, + file: PathBuf, + png_file: PathBuf, + old: lightcraft_pipeline::tone::CameraTone, + new: lightcraft_pipeline::tone::CameraTone, + calls: std::sync::Arc, + plain_loader: crate::media::FileLoader, +} + +impl SmartLookCase { + fn new(format: &str) -> Self { + let src = temp_dir(&format!("smartlook-src-{format}")); + let lib = temp_dir(&format!("smartlook-lib-{format}")); + write_png(&src.join("a.png"), 3); + write_png(&src.join("b.png"), 9); + let mut session = Session::new().with_fs(); + session.open_library(&lib, false).unwrap(); + session.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let (id, png_id) = (find(&session, "a.png"), find(&session, "b.png")); + // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) + let mut raw = (**session.catalog.photo(id).unwrap()).clone(); + raw.format = format.into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + session.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert_eq!(session.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); + assert!(!session.catalog.photo(png_id).unwrap().relative_wb()); + session.execute("library.select", &json!({"ids": [id.0, png_id.0], "active": id.0})).unwrap(); + let dir = session.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, session.catalog.photo(id).unwrap()); + let png_file = crate::smart::sharded_file_path(&dir, session.catalog.photo(png_id).unwrap()); + let (old, new) = (test_tone(1.5), test_tone(3.5)); + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let plain_loader = session.media.file_loader.clone().unwrap(); + session.media.file_loader = Some(look_loader(plain_loader.clone(), old, calls.clone())); + Self { src, lib, session, id, dir, file, png_file, old, new, calls, plain_loader } + } +} + +fn assert_current_smart_look_is_not_rebuilt(case: &mut SmartLookCase) { + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); - assert!(!crate::smart::is_stale(&file)); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); + assert!(!crate::smart::is_stale(&case.file)); // current header: kept as it is, the original is not even read - let before = std::fs::read(&file).unwrap(); - calls.store(0, Ordering::SeqCst); - s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let before = std::fs::read(&case.file).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + case.session.media.file_loader = Some(look_loader(case.plain_loader.clone(), case.new, case.calls.clone())); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt"); - assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(std::fs::read(&case.file).unwrap(), before, "a current proxy is not rebuilt"); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 0); +} +fn keep_offline_previews(case: &mut SmartLookCase) -> (Vec, Vec) { // old headers (no field), originals offline: kept unchanged, and still marked stale - for f in [&file, &png_file] { + for f in [&case.file, &case.png_file] { edit_smart_header(f, |h| { h.as_object_mut().unwrap().remove("look_version"); }); } - assert!(crate::smart::is_stale(&file), "no field = version 0"); - let old_bytes = std::fs::read(&file).unwrap(); - let png_bytes = std::fs::read(&png_file).unwrap(); - std::fs::rename(&src, src.with_extension("offline")).unwrap(); - s.media.forget(id); - s.media.forget(png); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert!(crate::smart::is_stale(&case.file), "no field = version 0"); + let old_bytes = std::fs::read(&case.file).unwrap(); + let png_bytes = std::fs::read(&case.png_file).unwrap(); + std::fs::rename(&case.src, case.src.with_extension("offline")).unwrap(); + case.session.media.forget(case.id); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}"); assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); - assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched"); - assert!(crate::smart::is_stale(&file), "and it stays marked for the next time"); - assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes, "offline: the stored preview and curve are untouched"); + assert!(crate::smart::is_stale(&case.file), "and it stays marked for the next time"); + assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(case.old), "the old look is still there"); // ... and the opening refresh keeps it too, then marks the folder checked - assert!(!crate::smart::look_checked(&dir)); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert!(std::fs::read(&file).unwrap() == old_bytes); - assert!(crate::smart::look_checked(&dir)); + assert!(!crate::smart::look_checked(&case.dir)); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + assert!(crate::smart::look_checked(&case.dir)); + (old_bytes, png_bytes) +} +fn refresh_online_previews(case: &mut SmartLookCase, old_bytes: &[u8], png_bytes: &[u8]) { // the original comes back: the scan has run at this version, so the next opening leaves it; // Build Smart Previews rebuilds it with the current look - std::fs::rename(src.with_extension("offline"), &src).unwrap(); - calls.store(0, Ordering::SeqCst); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version"); - assert!(std::fs::read(&file).unwrap() == old_bytes); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look"); + std::fs::rename(case.src.with_extension("offline"), &case.src).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none(), "checked once per version"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(1), 1), "{r}"); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, Some(case.new), "rebuilt with the current look"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes, "a PNG's proxy is never rebuilt for the look"); // a folder checked at an older version (or not at all) is scanned once at the next opening - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - std::fs::write(dir.join("look-version"), b"0\n").unwrap(); - assert!(!crate::smart::look_checked(&dir)); - s.media.forget(id); - calls.store(0, Ordering::SeqCst); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes); - assert!(crate::smart::look_checked(&dir)); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + std::fs::write(case.dir.join("look-version"), b"0\n").unwrap(); + assert!(!crate::smart::look_checked(&case.dir)); + case.session.media.forget(case.id); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 1, "only the raw's original is read"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes); + assert!(crate::smart::look_checked(&case.dir)); // once only - let rebuilt = std::fs::read(&file).unwrap(); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none()); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}"); - assert!(std::fs::read(&file).unwrap() == rebuilt); + let rebuilt = std::fs::read(&case.file).unwrap(); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(0), 1), "{r}"); + assert_eq!(std::fs::read(&case.file).unwrap(), rebuilt); // Build Smart Previews refreshes a stale one the same way - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - s.media.file_loader = Some(plain); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + case.session.media.file_loader = Some(case.plain_loader.clone()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(r["refreshed"], 1, "{r}"); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); - let _ = std::fs::remove_dir_all(&src); - let _ = std::fs::remove_dir_all(&lib); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); } /// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file