test(engine): factor complex regression scenarios

This commit is contained in:
Dylan M. Taylor
2026-10-28 03:24:18 +00:00
parent 47839a61db
commit 4d8369b091
7 changed files with 401 additions and 342 deletions
+16 -27
View File
@@ -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)));
+17 -1
View File
@@ -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);
+10
View File
@@ -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));
@@ -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] {
@@ -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<NativePartial> {
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
}
+110 -106
View File
@@ -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");
+116 -82
View File
@@ -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<std::sync::atomic::AtomicUsize>,
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<u8>, Vec<u8>) {
// 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