diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ec7c9f7e7..7d4de3440 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -950,7 +950,13 @@ impl Session { return Vec::new(); } let Some(old) = self.develop_of(id) else { return Vec::new() }; - let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() }; + let mut old_json = old.to_json(); + let mut new_json = new.to_json(); + // False is omitted from stored profile JSON. Keep it explicit for this comparison so + // switching camera local tone mapping off is a change without copying untouched values. + old_json["profile"]["camera_local_tone"] = Value::Bool(old.profile.camera_local_tone); + new_json["profile"]["camera_local_tone"] = Value::Bool(new.profile.camera_local_tone); + let Some(mut delta) = json_delta(&old_json, &new_json) else { return Vec::new() }; if let Some(o) = delta.as_object_mut() { for k in ["spots", "red_eye", "version", "process"] { o.remove(k); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 400135ce0..8fc80f501 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -917,6 +917,101 @@ fn auto_sync_carries_only_the_changed_settings() { assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.exposure, 0.7); } +#[test] +fn auto_sync_camera_local_tone_off_preserves_other_settings() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + for (id, (profile, amount, contrast)) in ids.iter().zip([("lc.color", 100, 0), ("lc.vivid", 37, 25), ("lc.mono", 63, -20)]) { + s.execute("library.select", &json!({"ids": [id]})).unwrap(); + let settings = json!({"profile": {"id": profile, "amount": amount}, "light": {"contrast": contrast}}); + s.execute("develop.merge", &json!({"settings": settings})).unwrap(); + } + let initial: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect(); + assert!(initial.iter().all(|d| !d.profile.camera_local_tone)); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap(); + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + let on = json!({"settings": {"profile": {"camera_local_tone": true}}, "label": "Camera Local Tone Mapping"}); + let off = json!({"settings": {"profile": {"camera_local_tone": false}}, "label": "Camera Local Tone Mapping"}); + + // Positive control: On reaches both selected photos, changing only this setting. + s.execute("develop.merge", &on).unwrap(); + let mut synced = initial.clone(); + for d in synced.iter_mut().take(2) { + d.profile.camera_local_tone = true; + } + for (id, expected) in ids.iter().zip(&synced) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "On: photo {id}"); + } + + let undo_before = s.undo.len(); + let history_before: Vec<_> = ids.iter().map(|id| s.catalog.photo(PhotoId(*id)).unwrap().history.len()).collect(); + s.execute("develop.merge", &off).unwrap(); + assert!(!s.develop_of(PhotoId(ids[0])).unwrap().profile.camera_local_tone); + assert!(!s.develop_of(PhotoId(ids[1])).unwrap().profile.camera_local_tone, "Auto Sync must carry camera local tone Off"); + assert_eq!(s.undo.len(), undo_before + 1, "Off is one undoable edit"); + for (i, (id, expected)) in ids.iter().zip(&initial).enumerate() { + let photo = s.catalog.photo(PhotoId(*id)).unwrap(); + assert_eq!(photo.develop.as_ref(), expected, "Off: photo {id}"); + assert_eq!(photo.history.len(), history_before[i] + usize::from(i < 2), "Off history: photo {id}"); + let stored = photo.develop.to_json(); + assert!(stored["profile"].get("camera_local_tone").is_none(), "false stays omitted: photo {id}"); + assert_eq!(&lightcraft_develop::DevelopSettings::from_json(&stored).unwrap(), expected); + } + + // One Undo/Redo restores the complete settings of both selected photos, not the third. + s.execute("edit.undo", &json!({})).unwrap(); + for (id, expected) in ids.iter().zip(&synced) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Undo: photo {id}"); + } + s.execute("edit.redo", &json!({})).unwrap(); + for (id, expected) in ids.iter().zip(&initial) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Redo: photo {id}"); + } +} + +#[test] +fn auto_sync_camera_local_tone_changes_only_when_edited() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); + let settings = json!({"profile": {"id": "lc.vivid", "amount": 37, "camera_local_tone": true}, "light": {"contrast": 25}}); + s.execute("develop.merge", &json!({"settings": settings})).unwrap(); + let mut expected: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect(); + assert!(!expected[0].profile.camera_local_tone); + assert!(expected[1].profile.camera_local_tone); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap(); + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + + // An unrelated edit must not copy the active photo's unchanged false over the peer's true. + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap(); + for d in expected.iter_mut().take(2) { + d.light.exposure = 0.7; + } + for (id, settings) in ids.iter().zip(&expected) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unrelated edit: photo {id}"); + } + + let peer_history = s.catalog.photo(PhotoId(ids[1])).unwrap().history.len(); + s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": false}}})).unwrap(); + assert_eq!(s.catalog.photo(PhotoId(ids[1])).unwrap().history.len(), peer_history, "unchanged toggle adds no peer history"); + for (id, settings) in ids.iter().zip(&expected) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unchanged toggle: photo {id}"); + } + + // Direct unchanged edits may still add an active undo step. With Auto Sync off, real + // Boolean changes affect only the active photo; selected and unselected peers stay exact. + s.execute("develop.autoSync", &json!({"on": false})).unwrap(); + for on in [true, false] { + s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": on}}})).unwrap(); + expected[0].profile.camera_local_tone = on; + for (id, settings) in ids.iter().zip(&expected) { + assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Auto Sync disabled: photo {id}"); + } + } +} + #[test] fn json_delta_keeps_only_changes() { let a = json!({"light": {"exposure": 0, "contrast": 5}, "masks": [1], "x": 1});