LRC-DEV-AUTOSYNC: sync camera local tone Off

Compare the typed camera-local-tone flag on both sides of the Auto Sync delta so selected peers receive Off while retaining their own profile id, amount, and unrelated settings. Add regressions for propagation, unchanged values, selection boundaries, and history/serialization behavior.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
sanyamsingh001
2026-10-18 12:00:11 +10:00
co-authored by Codex
parent 0da998f24d
commit bc071bb57b
4 changed files with 116 additions and 2 deletions
+7 -1
View File
@@ -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);
+95
View File
@@ -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<u64> = 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<u64> = 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});
+1
View File
@@ -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
+13 -1
View File
@@ -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();