mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
@@ -26,8 +26,11 @@ fn section_param(p: &Value, c: &str) -> Result<Section> {
|
||||
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
|
||||
}
|
||||
|
||||
fn groups_param(p: &Value) -> Option<Vec<SettingsGroup>> {
|
||||
p.get("groups").and_then(|g| serde_json::from_value(g.clone()).ok())
|
||||
fn groups_param(p: &Value, c: &str) -> Result<Option<Vec<SettingsGroup>>> {
|
||||
match p.get("groups") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(c, format!("invalid `groups`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
@@ -782,8 +785,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{groups?: [settingsGroup]} (default: all but crop, masks, remove)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.copy")?;
|
||||
if let Some(g) = groups_param(p) {
|
||||
let c = "develop.copy";
|
||||
let id = active(s, c)?;
|
||||
if let Some(g) = groups_param(p, c)? {
|
||||
s.copy_groups = g;
|
||||
}
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
@@ -802,7 +806,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = "develop.pastePrevious";
|
||||
let prev =
|
||||
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
|
||||
let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
|
||||
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
|
||||
let ops = ids
|
||||
@@ -823,8 +827,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",
|
||||
has_clipboard,
|
||||
|s, p| {
|
||||
let c = "develop.paste";
|
||||
let mut clip = s.clipboard.clone().unwrap_or_default();
|
||||
if let Some(only) = groups_param(p) {
|
||||
if let Some(only) = groups_param(p, c)? {
|
||||
// the clipboard holds whole groups: rebuild it with just the chosen (copied) ones
|
||||
let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();
|
||||
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
|
||||
@@ -849,8 +854,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{groups?} — copy the active photo's settings to all selected photos",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.sync")?;
|
||||
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
|
||||
let c = "develop.sync";
|
||||
let id = active(s, c)?;
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let partial = lightcraft_develop::extract_groups(&d, &groups);
|
||||
let ops = s
|
||||
@@ -979,10 +985,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
cmd!("preset.create", "Create Preset", ["Photo"], None, "{name, group?, groups?: [settingsGroup]}", has_active, |s, p| {
|
||||
let id = active(s, "preset.create")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("preset.create", "missing name"))?;
|
||||
let c = "preset.create";
|
||||
let id = active(s, c)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(c, "missing name"))?;
|
||||
let group = str_param(p, "group").unwrap_or("User Presets");
|
||||
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
|
||||
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
|
||||
@@ -1026,7 +1033,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = "preset.update";
|
||||
let id = active(s, c)?;
|
||||
let current = s.develop_of(id).unwrap_or_default().to_json();
|
||||
let groups = groups_param(p);
|
||||
let groups = groups_param(p, c)?;
|
||||
let pr = user_preset(s, p, c)?;
|
||||
pr.settings = match groups {
|
||||
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
|
||||
|
||||
@@ -1217,3 +1217,231 @@ fn albums_list_survives_a_catalog_that_loops() {
|
||||
let r = s.execute("albums.list", &json!({})).unwrap();
|
||||
assert!(r.to_string().contains("\"F\""), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_paste_validates_groups_and_rejects_malformed_with_zero_mutations() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
// Photo A has exposure = 1.0, vibrance = 30.0
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
|
||||
s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
|
||||
|
||||
// 1. Invalid group names are rejected
|
||||
for invalid_group in ["bogusGroup", "LIGHT", "Exposure", "unknown"] {
|
||||
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": [invalid_group]})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 2. Incorrect JSON types are rejected
|
||||
for invalid_param in [
|
||||
json!("light"), // string instead of sequence
|
||||
json!(42), // number
|
||||
json!(true), // boolean
|
||||
json!({"name": "light"}), // object
|
||||
json!([123]), // array of non-string
|
||||
json!(["light", 456]), // mixed array with invalid type
|
||||
json!([true]), // array of bool
|
||||
] {
|
||||
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": invalid_param})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 3. Valid groups remain supported: pasting only "light" does not paste "color"
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": ["light"]})).unwrap();
|
||||
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_light.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
|
||||
|
||||
// 4. Omitted groups preserves default behavior (all copied groups pasted)
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_omitted.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
|
||||
// 5. Explicit null behaves as omitted
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": null})).unwrap();
|
||||
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_null.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_null.color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_sync_validates_groups_and_rejects_malformed_with_zero_mutations() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
// Setup: Photo A has exposure = 1.0, vibrance = 30.0
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
|
||||
|
||||
// Select Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
|
||||
|
||||
// 1. Invalid group names are rejected
|
||||
for invalid_group in ["bogusGroup", "LIGHT", "WhiteBalance", "invalid_name"] {
|
||||
let err = s.execute("develop.sync", &json!({"groups": [invalid_group]})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 2. Incorrect JSON types are rejected
|
||||
for invalid_param in [
|
||||
json!("light"), // string instead of sequence
|
||||
json!(100), // number
|
||||
json!(false), // boolean
|
||||
json!({"groups": "light"}), // object
|
||||
json!([999]), // array of non-string
|
||||
json!(["color", false]), // mixed array with invalid type
|
||||
json!([null]), // array with null
|
||||
] {
|
||||
let err = s.execute("develop.sync", &json!({"groups": invalid_param})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 3. Valid groups remain supported: syncing only "light" does not sync "color"
|
||||
s.execute("develop.sync", &json!({"groups": ["light"]})).unwrap();
|
||||
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_light.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
|
||||
|
||||
// Reselect Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
// 4. Omitted groups preserves default behavior (all default copy groups synced)
|
||||
s.execute("develop.sync", &json!({})).unwrap();
|
||||
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_omitted.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
|
||||
// Reselect Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
// 5. Explicit null behaves as omitted
|
||||
s.execute("develop.sync", &json!({"groups": null})).unwrap();
|
||||
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_null.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_null.color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_copy_and_paste_previous_validate_groups() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.5}})).unwrap();
|
||||
|
||||
// develop.copy rejects invalid group
|
||||
let copy_err = s.execute("develop.copy", &json!({"groups": ["notAGroup"]})).unwrap_err();
|
||||
assert!(matches!(copy_err, crate::EngineError::BadParams { .. }));
|
||||
|
||||
// develop.copy accepts valid groups
|
||||
s.execute("develop.copy", &json!({"groups": ["light"]})).unwrap();
|
||||
|
||||
// Switch active photo to photo_b so photo_a becomes previous_active
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
|
||||
// develop.pastePrevious rejects invalid group
|
||||
let prev_err = s.execute("develop.pastePrevious", &json!({"groups": ["badGroup"]})).unwrap_err();
|
||||
assert!(matches!(prev_err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
|
||||
// develop.pastePrevious accepts valid group
|
||||
s.execute("develop.pastePrevious", &json!({"groups": ["light"]})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_validate_groups_on_create_and_update() {
|
||||
let mut s = demo();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 25}})).unwrap();
|
||||
let initial_preset_count = s.presets.len();
|
||||
|
||||
// 1. preset.create rejects invalid group names and incorrect JSON types without creating a preset
|
||||
for invalid_groups in [json!(["bogusGroup"]), json!("light"), json!(123), json!([true]), json!(["light", 99])] {
|
||||
let err = s.execute("preset.create", &json!({"name": "Test", "groups": invalid_groups})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(s.presets.len(), initial_preset_count);
|
||||
}
|
||||
|
||||
// 2. preset.create with valid groups creates a preset with only those groups
|
||||
let res = s.execute("preset.create", &json!({"name": "ValidPreset", "groups": ["light"]})).unwrap();
|
||||
let pid = res["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(s.presets.len(), initial_preset_count + 1);
|
||||
let p = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert!(p.settings.get("light").is_some());
|
||||
assert!(p.settings.get("color").is_none());
|
||||
|
||||
// 3. preset.create with omitted groups uses default copy groups
|
||||
let res_omitted = s.execute("preset.create", &json!({"name": "DefaultPreset"})).unwrap();
|
||||
let pid_omitted = res_omitted["id"].as_str().unwrap().to_string();
|
||||
let p_omitted = s.presets.iter().find(|p| p.id == pid_omitted).unwrap();
|
||||
assert!(p_omitted.settings.get("light").is_some());
|
||||
assert!(p_omitted.settings.get("color").is_some());
|
||||
|
||||
// Change develop settings
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 2.0, "color.vibrance": 50}})).unwrap();
|
||||
|
||||
let preset_before_update = s.presets.iter().find(|p| p.id == pid).unwrap().clone();
|
||||
|
||||
// 4. preset.update rejects invalid group names and incorrect JSON types without modifying the preset
|
||||
for invalid_groups in [json!(["bogusGroup"]), json!("color"), json!(999), json!([false]), json!(["color", "bad"])] {
|
||||
let err = s.execute("preset.update", &json!({"id": pid, "groups": invalid_groups})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
let current = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(*current, preset_before_update);
|
||||
}
|
||||
|
||||
// 5. preset.update with valid groups updates specified groups
|
||||
s.execute("preset.update", &json!({"id": pid, "groups": ["light", "color"]})).unwrap();
|
||||
let p_after_valid = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(p_after_valid.settings["light"]["exposure"], 2.0);
|
||||
assert_eq!(p_after_valid.settings["color"]["vibrance"], 50.0);
|
||||
|
||||
// 6. preset.update with omitted groups updates existing preset keys
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": -1.0, "color.vibrance": -20}})).unwrap();
|
||||
s.execute("preset.update", &json!({"id": pid})).unwrap();
|
||||
let p_after_omitted = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(p_after_omitted.settings["light"]["exposure"], -1.0);
|
||||
assert_eq!(p_after_omitted.settings["color"]["vibrance"], -20.0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user