mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user