LRC-DEV-PROCESS: process versions, so a look change can ship without changing existing edits (#146)

Each photo's develop settings record the rendering process they are
interpreted by (`DevelopSettings::process`). There is one process, V1, the
pipeline as it is; nothing renders differently (180 CPU 8/16-bit and GPU
renders of procedural scenes, and the saved settings JSON and hash64, are
byte-identical to main).

Settings saved without the field load as V1 (a field-level serde default),
new settings get the latest (`Default`), and V1 is left out when saving, so
files and preview-cache keys stay as they were. A damaged value (anything
but a whole number up to 2^32 - 1) reads as V1 too, so it never costs the
rest of the settings (an unparsable `lc:settings` sidecar is ignored). Reset moves a photo to the
latest process, section and slider resets don't; virtual copies inherit
it; copy / paste / sync / Auto Sync / presets don't carry it; a number from
a newer LightCraft is kept and renders with the newest known process. The
process is not an edit (Edited badges, import-look checks). The base tone
map is chosen per process in one place (`finish::base_tone`, shared by CPU
and GPU). Photo > Update to Current Process (`develop.updateProcess`) moves
selected photos on an older process to the latest as one undo step. Its
menu enablement follows the selection; a call that names photos (`ids` /
`id`) is validated on those instead, through a new opt-in
`CommandSpec::explicit_targets` (execute checks enablement before the
handler sees `ids`). `develop.get` reports the process. The rule and how to add V2:
docs/process-versions.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alex Chambers
2026-10-09 17:03:45 +02:00
co-authored by Claude Opus 5.5
parent 8f924a9e60
commit 2e817a45f4
22 changed files with 876 additions and 37 deletions
+77 -2
View File
@@ -1,7 +1,7 @@
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
use lightcraft_catalog::{Op, PhotoId, Version};
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
use serde_json::{Value, json};
@@ -74,6 +74,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
fn outdated(s: &Session, id: PhotoId) -> bool {
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
}
fn already_current() -> String {
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
}
/// Update to Current Process applies to the selection (the selected photos, else the active
/// photo): one of them is on an older rendering process. Calls that name their photos are
/// checked by the command instead (`explicit_targets`).
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let sel = &s.selection;
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
}
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
/// names none (the selection applies).
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
if !super::names_photos(p) {
return Ok(None);
}
let given = match p.get("ids").filter(|v| !v.is_null()) {
Some(Value::Array(a)) => a.clone(),
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
None => p.get("id").cloned().into_iter().collect(),
};
if given.is_empty() {
return Err(bad(c, "`ids` names no photos"));
}
let ids = given
.iter()
.map(|v| match v.as_u64().map(PhotoId) {
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
_ => Err(bad(c, format!("no such photo {v}"))),
})
.collect::<Result<Vec<_>>>()?;
Ok(Some(ids))
}
/// The user preset named by `p.id` (built-ins can't be changed).
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
@@ -203,12 +246,44 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
};
s.develop_op(id, fresh, "Reset")
// a fresh start renders with the current process (a section or slider reset doesn't change it)
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
})
.collect();
s.commit("Reset", Op::Batch { ops })?;
ok()
}),
CommandSpec {
explicit_targets: true,
..cmd!(
"develop.updateProcess",
"Update to Current Process",
["Photo"],
None,
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
has_outdated_process,
|s, p| {
const C: &str = "develop.updateProcess";
const LABEL: &str = "Update to Current Process";
let targets = match named_photos(s, p, C)? {
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
Some(ids) => ids,
None => s.targets(p),
};
let ops: Vec<Op> = targets
.into_iter()
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter(|(_, d)| d.process.is_outdated())
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
.collect();
let n = ops.len();
if n > 0 {
s.commit(LABEL, Op::Batch { ops })?;
}
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
}
)
},
cmd!(
"develop.resetSection",
"Reset Section",
+11 -2
View File
@@ -54,6 +54,10 @@ pub struct CommandSpec {
pub run: Run,
/// Record in the journal (false for queries).
pub journal: bool,
/// `enabled` judges the selection (menus, shortcuts); a call that names its photos (`ids` /
/// `id`, see [`names_photos`]) skips it, and the command validates those photos itself.
/// Off unless a command opts in (`CommandSpec { explicit_targets: true, ..cmd!(…) }`).
pub explicit_targets: bool,
}
#[derive(Clone, Debug, Serialize)]
@@ -107,10 +111,10 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, explicit_targets: false }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, explicit_targets: false }
};
}
pub(crate) use cmd;
@@ -158,6 +162,11 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
// ---------- param helpers
/// The call names its target photos (`ids` or `id`, not null) instead of using the selection.
pub(crate) fn names_photos(p: &Value) -> bool {
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
}
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
+11 -2
View File
@@ -149,9 +149,18 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(v)
}),
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?} → the photo's develop settings; `process` is the rendering process they are interpreted by (see develop.updateProcess)", always, |s, p| {
let id = photo_arg(s, p, "develop.get")?;
Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null))
Ok(s.develop_of(id)
.map(|d| {
let mut v = d.to_json();
// saved settings leave V1 out (`ProcessVersion::is_legacy`); here it is always stated
if let Some(o) = v.as_object_mut() {
o.insert("process".into(), json!(d.process));
}
v
})
.unwrap_or(Value::Null))
}),
cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| {
let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default();
+9 -3
View File
@@ -352,7 +352,10 @@ impl Session {
/// Run a command by id. THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
// a command that validates the photos a call names isn't held back by the selection
if !(spec.explicit_targets && cmd::names_photos(params)) {
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
}
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
@@ -626,7 +629,8 @@ impl Session {
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
/// other selected photos: only the settings that changed; never spot removal or red eye (they
/// belong to one photo's pixels), nor history / snapshot restores.
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
/// history / snapshot restores.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
if !self.auto_sync
|| self.active() != Some(id)
@@ -639,7 +643,7 @@ impl Session {
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() };
if let Some(o) = delta.as_object_mut() {
for k in ["spots", "red_eye", "version"] {
for k in ["spots", "red_eye", "version", "process"] {
o.remove(k);
}
if o.is_empty() {
@@ -871,6 +875,8 @@ mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
+262
View File
@@ -0,0 +1,262 @@
//! Process versions through the commands (`docs/process-versions.md`): new photos and Reset get
//! the latest process, Update to Current Process is one undo step, copies inherit, copy / paste /
//! sync / presets / Auto Sync keep each photo's own, and numbers this build doesn't know survive a
//! save and a sidecar round trip.
use std::sync::Arc;
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::{DevelopSettings, ProcessVersion};
use serde_json::json;
use crate::{EngineError, Session};
/// No process is older than V1 yet: 0 (which no LightCraft writes) stands in for one.
const OLDER: ProcessVersion = ProcessVersion(0);
/// A number from a newer LightCraft.
const NEWER: ProcessVersion = ProcessVersion(ProcessVersion::LATEST.0 + 6);
fn process(s: &Session, id: PhotoId) -> ProcessVersion {
s.develop_of(id).unwrap().process
}
/// Put photo `id` on process `p` (with exposure `ev`) without going through a command.
fn put(s: &mut Session, id: PhotoId, p: ProcessVersion, ev: f64) {
let mut d = (*s.develop_of(id).unwrap()).clone();
d.process = p;
d.light.exposure = ev;
s.set_develop(id, d, "test").unwrap();
}
fn select(s: &mut Session, ids: &[PhotoId]) {
s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn update_info(s: &Session) -> crate::CommandInfo {
s.commands().into_iter().find(|c| c.id == "develop.updateProcess").unwrap()
}
#[test]
fn new_photos_and_reset_get_the_latest_process() {
let mut s = Session::with_demo();
assert!(s.catalog.photos().all(|p| p.develop.process == ProcessVersion::LATEST));
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(2).collect();
put(&mut s, ids[0], OLDER, 0.7);
put(&mut s, ids[1], NEWER, -0.3);
// a section or a slider reset leaves the process alone
select(&mut s, &ids[1..]);
s.execute("develop.resetSection", &json!({"section": "light"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.2})).unwrap();
s.execute("develop.resetControl", &json!({"control": "light.exposure"})).unwrap();
let d = s.develop_of(ids[1]).unwrap();
assert_eq!((d.process, d.light.exposure), (NEWER, 0.0));
// a full reset is a fresh start on the latest process, also from a newer one
select(&mut s, &ids);
s.execute("develop.reset", &json!({})).unwrap();
for id in &ids {
assert_eq!(process(&s, *id), ProcessVersion::LATEST);
assert!(!s.catalog.photo(*id).unwrap().is_edited());
}
// a photo whose default preset's look was recorded on an older process
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(process(&s, ids[0]), OLDER);
let mut p = (**s.catalog.photo(ids[0]).unwrap()).clone();
let mut look = p.camera_defaults();
look.light.contrast = 20.0;
look.process = OLDER;
p.import_look = Some(Arc::new(look));
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: p.id }).unwrap();
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("develop.reset", &json!({"ids": [ids[0].0]})).unwrap();
let d = s.develop_of(ids[0]).unwrap();
assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 20.0));
}
#[test]
fn update_to_current_process_is_one_undo_step_and_only_when_needed() {
let mut s = Session::with_demo();
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
select(&mut s, &ids);
// everything is current: disabled, with the reason
let info = update_info(&s);
assert!(!info.enabled && info.disabled_reason.as_deref().is_some_and(|r| r.contains("already on the current process")), "{info:?}");
assert!(matches!(s.execute("develop.updateProcess", &json!({})), Err(EngineError::Disabled(..))));
// in the Photo menu
assert_eq!(info.menu, vec!["Photo"]);
put(&mut s, ids[0], OLDER, 0.5);
put(&mut s, ids[1], OLDER, -0.5);
put(&mut s, ids[2], NEWER, 0.25);
select(&mut s, &ids);
assert!(update_info(&s).enabled);
let undo = s.undo.len();
let r = s.execute("develop.updateProcess", &json!({})).unwrap();
assert_eq!(r, json!({"changed": 2, "process": ProcessVersion::LATEST.0}));
assert_eq!(s.undo.len(), undo + 1, "one undo step");
assert_eq!(s.undo.last().unwrap().label, "Update to Current Process");
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]);
assert_eq!(process(&s, ids[2]), NEWER, "a newer process is never downgraded");
assert_eq!(s.develop_of(ids[0]).unwrap().light.exposure, 0.5, "the sliders keep their values");
assert_eq!(s.catalog.photo(ids[1]).unwrap().history.last().unwrap().label, "Update to Current Process");
assert!(!update_info(&s).enabled, "nothing left to update");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [OLDER; 2]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]);
// a photo on a newer process alone: nothing to update
select(&mut s, &ids[2..]);
assert!(!update_info(&s).enabled);
// the active photo when nothing else is selected
put(&mut s, ids[0], OLDER, 0.5);
s.selection = crate::Selection { ids: Vec::new(), active: Some(ids[0]) };
assert!(update_info(&s).enabled);
assert_eq!(s.execute("develop.updateProcess", &json!({})).unwrap()["changed"], 1);
}
#[test]
fn virtual_copies_inherit_the_process() {
let mut s = Session::with_demo();
let src = s.visible_cloned()[0];
put(&mut s, src, NEWER, 0.4);
select(&mut s, &[src]);
let r = s.execute("photo.virtualCopy", &json!({})).unwrap();
let copy = PhotoId(r["ids"][0].as_u64().unwrap());
assert_eq!(process(&s, copy), NEWER);
assert!(s.develop_of(copy).unwrap() == s.develop_of(src).unwrap());
}
#[test]
fn copy_paste_sync_presets_and_auto_sync_keep_each_photos_process() {
let mut s = Session::with_demo();
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
let (src, a, b) = (ids[0], ids[1], ids[2]);
put(&mut s, src, NEWER, 1.0);
put(&mut s, b, OLDER, 0.0);
select(&mut s, &[src]);
s.execute("develop.copy", &json!({"groups": lightcraft_develop::SettingsGroup::ALL})).unwrap();
select(&mut s, &[a, b]);
s.execute("develop.paste", &json!({})).unwrap();
assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]);
assert_eq!(s.develop_of(b).unwrap().light.exposure, 1.0, "the values are pasted");
// sync from the active photo
put(&mut s, src, NEWER, -1.0);
s.selection = crate::Selection { ids: vec![src, a, b], active: Some(src) };
s.execute("develop.sync", &json!({})).unwrap();
assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]);
assert_eq!(s.develop_of(a).unwrap().light.exposure, -1.0);
// a preset made from the photo
select(&mut s, &[src]);
// (not Grain: its integer seed doesn't survive scaling, a separate issue)
let groups: Vec<_> = lightcraft_develop::SettingsGroup::ALL.into_iter().filter(|g| *g != lightcraft_develop::SettingsGroup::Grain).collect();
let pid = s.execute("preset.create", &json!({"name": "Everything", "groups": groups})).unwrap()["id"].clone();
put(&mut s, b, OLDER, 0.0);
select(&mut s, &[b]);
s.execute("preset.apply", &json!({"id": pid, "amount": 50})).unwrap();
assert_eq!(process(&s, b), OLDER);
assert_eq!(s.develop_of(b).unwrap().light.exposure, -0.5, "the preset applied (at 50 %)");
// Auto Sync carries an edit of the active photo, not its process
s.selection = crate::Selection { ids: vec![src, a], active: Some(src) };
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
s.execute("develop.merge", &json!({"settings": {"process": OLDER.0, "light": {"contrast": 15}}})).unwrap();
assert_eq!(process(&s, src), OLDER, "Apply Settings JSON sets what it names");
let d = s.develop_of(a).unwrap();
assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 15.0));
}
#[test]
fn process_numbers_survive_a_reopen_and_develop_get_reports_them() {
let dir = std::env::temp_dir().join(format!("lc-process-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
s.open_library(&dir, true).unwrap();
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(2).collect();
select(&mut s, &ids[..1]);
s.execute("develop.merge", &json!({"settings": {"process": NEWER.0, "light": {"exposure": 0.5}}})).unwrap();
assert_eq!(s.execute("develop.get", &json!({"id": ids[0].0})).unwrap()["process"], json!(NEWER.0));
assert_eq!(s.execute("develop.get", &json!({"id": ids[1].0})).unwrap()["process"], json!(ProcessVersion::LATEST.0));
// it renders (with the newest process this build has)
assert!(s.render_now(ids[0], 48, 48).is_ok());
let expect = s.catalog.to_snapshot();
drop(s);
let mut s = Session::new();
s.open_library(&dir, false).unwrap();
assert_eq!(s.catalog.to_snapshot(), expect);
assert_eq!(process(&s, ids[0]), NEWER);
assert_eq!(process(&s, ids[1]), ProcessVersion::LATEST);
drop(s);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sidecars_keep_the_process_and_interchange_edits_get_the_latest() {
use crate::sidecar::{DevelopPatch, merge_into, parse_sidecar, sidecar_packet};
let mut s = Session::with_demo();
let id = s.visible_cloned()[0];
// our own settings round-trip with their process; V1 settings say nothing (as before process
// versions, so sidecars written then read as V1)
put(&mut s, id, NEWER, 0.3);
let p = s.catalog.photo(id).unwrap().clone();
let back = parse_sidecar(&sidecar_packet(&p, &s.catalog), false).unwrap();
let Some(DevelopPatch::Full(d)) = &back.develop else { panic!("no lc:settings") };
assert_eq!((d.process, d.light.exposure), (NEWER, 0.3));
put(&mut s, id, ProcessVersion::V1, 0.3);
let packet = sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog);
assert!(!packet.contains("process"), "{packet}");
let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, false).unwrap().develop else { panic!("no lc:settings") };
assert_eq!(d.process, ProcessVersion::V1);
// a damaged process value reads as V1 and costs nothing else: the sidecar's edits still come back
for bad in ["-1", "1.5", "\"two\"", "null", "4294967296"] {
let json = format!(r#"{{"process": {bad}, "light": {{"exposure": 0.5}}}}"#);
let packet = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&json));
let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, false).unwrap().develop else { panic!("{bad}: the edit was dropped") };
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5), "{bad}");
}
// `crs:` edits (other raw developers, Lightroom): their ProcessVersion numbers another
// renderer and is ignored; mapped like a preset, they leave the photo's process as it is,
// which for a newly imported photo is the latest
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:ProcessVersion="11.0" crs:Exposure2012="+1.10" crs:Contrast2012="+20"/></rdf:RDF></x:xmpmeta>"#;
let sc = parse_sidecar(xmp, true).unwrap();
let Some(DevelopPatch::Partial(partial)) = &sc.develop else { panic!("no crs: edit") };
assert!(partial.get("process").is_none(), "{partial}");
let mut fresh = Photo::new(PhotoId(999), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00");
assert!(merge_into(&mut fresh, &sc, "2026-10-09T00:00:00"));
assert_eq!((fresh.develop.process, fresh.develop.light.exposure), (ProcessVersion::LATEST, 1.1));
let mut older = fresh.clone();
older.develop = Arc::new(DevelopSettings { process: OLDER, ..Default::default() });
assert!(merge_into(&mut older, &sc, "2026-10-09T00:00:00"));
assert_eq!(older.develop.process, OLDER);
}
#[test]
fn update_to_current_process_checks_named_photos_not_the_selection() {
let mut s = Session::with_demo();
let ids: Vec<PhotoId> = s.visible_cloned().into_iter().take(3).collect();
let (old, current, other) = (ids[0], ids[1], ids[2]);
put(&mut s, old, OLDER, 0.5);
// a different, current photo is selected: the menu item is off, but naming the photo works
select(&mut s, &[current]);
assert!(!update_info(&s).enabled);
let undo = s.undo.len();
assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0]})).unwrap()["changed"], 1);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(process(&s, old), ProcessVersion::LATEST);
assert_eq!(s.develop_of(old).unwrap().light.exposure, 0.5);
// with nothing selected at all, by `ids` or `id`
put(&mut s, old, OLDER, 0.5);
put(&mut s, other, OLDER, 0.0);
s.selection = crate::Selection { ids: Vec::new(), active: None };
assert!(!update_info(&s).enabled, "no selection");
assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0, current.0]})).unwrap()["changed"], 1);
assert_eq!(s.execute("develop.updateProcess", &json!({"id": other.0})).unwrap()["changed"], 1);
assert_eq!([process(&s, old), process(&s, other)], [ProcessVersion::LATEST; 2]);
// named photos are validated: all current, unknown, malformed or none
let err = s.execute("develop.updateProcess", &json!({"ids": [current.0]})).unwrap_err();
assert!(matches!(&err, EngineError::Disabled(_, why) if why.contains("already on the current process")), "{err}");
for bad in [json!({"ids": [99_999]}), json!({"ids": ["x"]}), json!({"ids": "x"}), json!({"ids": []}), json!({"id": -1})] {
assert!(matches!(s.execute("develop.updateProcess", &bad), Err(EngineError::BadParams { .. })), "{bad}");
}
// `ids: null` names nothing: the selection decides (and there is none)
assert!(matches!(s.execute("develop.updateProcess", &json!({"ids": null})), Err(EngineError::Disabled(..))));
}