diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a282ce3e..9cf8d939a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -900,7 +900,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}); diff --git a/crates/ui-egui/src/tests_menubar.rs b/crates/ui-egui/src/tests_menubar.rs index 8fbc13608..758ac595a 100644 --- a/crates/ui-egui/src/tests_menubar.rs +++ b/crates/ui-egui/src/tests_menubar.rs @@ -116,6 +116,7 @@ fn hovering_another_title_switches_the_open_menu() { #[test] fn clicking_another_title_opens_it_in_one_click() { let mut h = app([1280.0, 703.0]); + assert!(h.step_until(SETTLE, |h| widget(h, "menu:Edit").is_some()), "Edit menu title was not registered"); go(&mut h, "ui.clickWidget", "menu:Edit"); assert_eq!(open_menu(&h).as_deref(), Some("menu:Edit")); // move away from the titles first so only the click can be what switches diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 9e0db5297..105e90db3 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -84,8 +84,20 @@ fn open_and_scan(h: &mut Headless, folder: &str) { // the rows above it hold one folder each and open by themselves let row = format!("source:libfolder:{folder}"); assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}"); + let before = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}")); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel"); + // Off-screen rows are registered too. Bring this row into view before the real right-click. + crate::tests_panels::scroll_sidebar(h, panel.center().y - before.center().y); let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}")); - let c = rect.center(); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel"); + let visible = rect.intersect(panel); + let c = visible.center(); + let drawn = h.app.widgets.iter().any(|(w, _)| *w == format!("label:libfolder:{folder}")); + let clips: Vec<_> = h.view.shapes.iter().filter(|s| s.shape.visual_bounding_rect().intersects(rect)).map(|s| s.clip_rect).collect(); + eprintln!( + "sync row geometry: before={before:?} row={rect:?} panel={panel:?} visible={visible:?} click={c:?} drawn={drawn} paint_clips={clips:?}" + ); + assert!(drawn && visible.is_positive() && visible.height() >= rect.height() - 0.5, "folder row must be drawn and vertically in view"); let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step();